mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 08:42:35 +08:00
Compare commits
2 Commits
v2026.6.8
...
codex/maco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02ff01c3c5 | ||
|
|
bc94742ccb |
@@ -28,6 +28,7 @@ let talkPhaseSoundsEnabledKey = "openclaw.talkPhaseSoundsEnabled"
|
||||
let talkShiftToStopEnabledKey = "openclaw.talkShiftToStopEnabled"
|
||||
let iconOverrideKey = "openclaw.iconOverride"
|
||||
let connectionModeKey = "openclaw.connectionMode"
|
||||
let gatewayNativeHostEnabledKey = "openclaw.gatewayNativeHostEnabled"
|
||||
let remoteTargetKey = "openclaw.remoteTarget"
|
||||
let remoteIdentityKey = "openclaw.remoteIdentity"
|
||||
let remoteProjectRootKey = "openclaw.remoteProjectRoot"
|
||||
|
||||
@@ -7,8 +7,14 @@ enum GatewayAutostartPolicy {
|
||||
|
||||
static func shouldEnsureLaunchAgent(
|
||||
mode: AppState.ConnectionMode,
|
||||
paused: Bool) -> Bool
|
||||
paused: Bool,
|
||||
defaults: UserDefaults = .standard,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool
|
||||
{
|
||||
self.shouldStartGateway(mode: mode, paused: paused)
|
||||
self.shouldStartGateway(mode: mode, paused: paused) &&
|
||||
!GatewayNativeHostPolicy.shouldPreferNativeHost(
|
||||
mode: mode,
|
||||
defaults: defaults,
|
||||
environment: environment)
|
||||
}
|
||||
}
|
||||
|
||||
30
apps/macos/Sources/OpenClaw/GatewayNativeHostPolicy.swift
Normal file
30
apps/macos/Sources/OpenClaw/GatewayNativeHostPolicy.swift
Normal file
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayNativeHostPolicy {
|
||||
static let environmentKey = "OPENCLAW_MAC_NATIVE_GATEWAY"
|
||||
|
||||
private static let enabledValues: Set<String> = ["1", "true", "yes", "on", "native", "app"]
|
||||
private static let disabledValues: Set<String> = ["0", "false", "no", "off", "launchd"]
|
||||
|
||||
static func shouldPreferNativeHost(
|
||||
mode: AppState.ConnectionMode,
|
||||
defaults: UserDefaults = .standard,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool
|
||||
{
|
||||
guard mode == .local else { return false }
|
||||
if let envValue = environment[self.environmentKey].map(self.normalizeFlagValue),
|
||||
!envValue.isEmpty
|
||||
{
|
||||
if self.disabledValues.contains(envValue) { return false }
|
||||
if self.enabledValues.contains(envValue) { return true }
|
||||
}
|
||||
if defaults.object(forKey: gatewayNativeHostEnabledKey) != nil {
|
||||
return defaults.bool(forKey: gatewayNativeHostEnabledKey)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private static func normalizeFlagValue(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ final class GatewayProcessManager {
|
||||
private var environmentRefreshTask: Task<Void, Never>?
|
||||
private var lastEnvironmentRefresh: Date?
|
||||
private var logRefreshTask: Task<Void, Never>?
|
||||
private var nativeGatewayProcess: Process?
|
||||
private var nativeGatewayOutputPipes: [Pipe] = []
|
||||
#if DEBUG
|
||||
private var testingConnection: GatewayConnection?
|
||||
private var testingSkipControlChannelRefresh = false
|
||||
@@ -80,6 +82,11 @@ final class GatewayProcessManager {
|
||||
|
||||
func ensureLaunchAgentEnabledIfNeeded() async {
|
||||
guard !CommandResolver.connectionModeIsRemote() else { return }
|
||||
if self.prefersNativeHostedGateway() {
|
||||
self.appendLog("[gateway] native host active; launchd auto-enable skipped\n")
|
||||
self.logger.info("gateway launchd auto-enable skipped (native host active)")
|
||||
return
|
||||
}
|
||||
if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
|
||||
self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
|
||||
self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
|
||||
@@ -117,6 +124,14 @@ final class GatewayProcessManager {
|
||||
// First try to latch onto an already-running gateway to avoid spawning a duplicate.
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.prefersNativeHostedGateway() {
|
||||
let stoppedLaunchd = await self.stopLaunchdGatewayForNativeHostIfNeeded()
|
||||
if !stoppedLaunchd, await self.attachExistingGatewayIfAvailable() {
|
||||
return
|
||||
}
|
||||
await self.startNativeGateway()
|
||||
return
|
||||
}
|
||||
if await self.attachExistingGatewayIfAvailable() {
|
||||
return
|
||||
}
|
||||
@@ -130,6 +145,7 @@ final class GatewayProcessManager {
|
||||
self.lastFailureReason = nil
|
||||
self.status = .stopped
|
||||
self.logger.info("gateway stop requested")
|
||||
self.stopNativeGateway()
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return
|
||||
}
|
||||
@@ -171,6 +187,7 @@ final class GatewayProcessManager {
|
||||
|
||||
func refreshLog() {
|
||||
guard self.logRefreshTask == nil else { return }
|
||||
guard self.nativeGatewayProcess == nil else { return }
|
||||
let path = GatewayLaunchAgentManager.launchdGatewayLogPath()
|
||||
let limit = self.logLimit
|
||||
self.logRefreshTask = Task { [weak self] in
|
||||
@@ -357,6 +374,154 @@ final class GatewayProcessManager {
|
||||
self.logger.warning("gateway start timed out")
|
||||
}
|
||||
|
||||
private func prefersNativeHostedGateway() -> Bool {
|
||||
GatewayNativeHostPolicy.shouldPreferNativeHost(
|
||||
mode: .local,
|
||||
defaults: .standard,
|
||||
environment: ProcessInfo.processInfo.environment)
|
||||
}
|
||||
|
||||
private func stopLaunchdGatewayForNativeHostIfNeeded() async -> Bool {
|
||||
guard !GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() else {
|
||||
self.appendLog(
|
||||
"[gateway] launchd stop skipped (attach-only); " +
|
||||
"native host will attach if a listener is present\n")
|
||||
return false
|
||||
}
|
||||
guard await GatewayLaunchAgentManager.isLoaded() else { return false }
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
self.appendLog("[gateway] disabling launchd job before native host start\n")
|
||||
let err = await GatewayLaunchAgentManager.set(
|
||||
enabled: false,
|
||||
bundlePath: bundlePath,
|
||||
port: GatewayEnvironment.gatewayPort())
|
||||
if let err {
|
||||
self.appendLog("[gateway] launchd disable before native host failed: \(err)\n")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func startNativeGateway() async {
|
||||
self.existingGatewayDetails = nil
|
||||
let resolution = await Task.detached(priority: .utility) {
|
||||
GatewayEnvironment.resolveGatewayCommand()
|
||||
}.value
|
||||
self.environmentStatus = resolution.status
|
||||
guard let command = resolution.command, let executable = command.first else {
|
||||
self.status = .failed(resolution.status.message)
|
||||
self.lastFailureReason = resolution.status.message
|
||||
self.logger.error("native gateway command resolve failed: \(resolution.status.message)")
|
||||
return
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: executable)
|
||||
process.arguments = Array(command.dropFirst())
|
||||
process.environment = self.nativeGatewayEnvironment()
|
||||
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
self.installNativeGatewayOutputHandler(pipe: outputPipe, label: "stdout")
|
||||
self.installNativeGatewayOutputHandler(pipe: errorPipe, label: "stderr")
|
||||
|
||||
process.terminationHandler = { [weak self] terminated in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self, self.nativeGatewayProcess === terminated else { return }
|
||||
self.nativeGatewayProcess = nil
|
||||
for pipe in self.nativeGatewayOutputPipes {
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
}
|
||||
self.nativeGatewayOutputPipes.removeAll()
|
||||
let status = terminated.terminationStatus
|
||||
self.appendLog("[gateway] native-hosted gateway exited with status \(status)\n")
|
||||
if self.desiredActive {
|
||||
let message = "Native-hosted Gateway exited with status \(status)"
|
||||
self.status = .failed(message)
|
||||
self.lastFailureReason = message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
let message = "Native-hosted Gateway failed to start: \(error.localizedDescription)"
|
||||
self.status = .failed(message)
|
||||
self.lastFailureReason = message
|
||||
self.appendLog("[gateway] \(message)\n")
|
||||
self.logger.error("\(message, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
self.nativeGatewayProcess = process
|
||||
self.nativeGatewayOutputPipes = [outputPipe, errorPipe]
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
self.appendLog("[gateway] started native-hosted gateway pid \(process.processIdentifier) on port \(port)\n")
|
||||
self.logger.info("native-hosted gateway started pid=\(process.processIdentifier)")
|
||||
|
||||
let deadline = Date().addingTimeInterval(6)
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return }
|
||||
if !process.isRunning {
|
||||
let message = "Native-hosted Gateway exited before readiness"
|
||||
self.status = .failed(message)
|
||||
self.lastFailureReason = message
|
||||
return
|
||||
}
|
||||
do {
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
let details = "native host, pid \(process.processIdentifier)"
|
||||
self.clearLastFailure()
|
||||
self.status = .running(details: details)
|
||||
self.logger.info("native-hosted gateway ready details=\(details)")
|
||||
self.refreshControlChannelIfNeeded(reason: "native gateway started")
|
||||
return
|
||||
} catch {
|
||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
self.status = .failed("Native-hosted Gateway did not start in time")
|
||||
self.lastFailureReason = "native gateway start timeout"
|
||||
self.stopNativeGateway()
|
||||
self.logger.warning("native-hosted gateway start timed out")
|
||||
}
|
||||
|
||||
private func nativeGatewayEnvironment() -> [String: String] {
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
env["OPENCLAW_MAC_NATIVE_HOST"] = "1"
|
||||
env["OPENCLAW_MAC_NATIVE_HOST_BUNDLE_ID"] = Bundle.main.bundleIdentifier ?? launchdLabel
|
||||
env["OPENCLAW_GATEWAY_SUPERVISOR"] = "openclaw-macos-app"
|
||||
return env
|
||||
}
|
||||
|
||||
private func installNativeGatewayOutputHandler(pipe: Pipe, label: String) {
|
||||
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
let text = String(data: data, encoding: .utf8) ?? "<\(data.count) bytes>\n"
|
||||
Task { @MainActor [weak self] in
|
||||
self?.appendLog("[gateway \(label)] \(text)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopNativeGateway() {
|
||||
let process = self.nativeGatewayProcess
|
||||
self.nativeGatewayProcess = nil
|
||||
for pipe in self.nativeGatewayOutputPipes {
|
||||
pipe.fileHandleForReading.readabilityHandler = nil
|
||||
}
|
||||
self.nativeGatewayOutputPipes.removeAll()
|
||||
guard let process, process.isRunning else { return }
|
||||
self.appendLog("[gateway] stopping native-hosted gateway pid \(process.processIdentifier)\n")
|
||||
process.terminate()
|
||||
}
|
||||
|
||||
private func appendLog(_ chunk: String) {
|
||||
self.log.append(chunk)
|
||||
if self.log.count > self.logLimit {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@@ -10,10 +11,11 @@ struct GatewayAutostartPolicyTests {
|
||||
#expect(!GatewayAutostartPolicy.shouldStartGateway(mode: .unconfigured, paused: false))
|
||||
}
|
||||
|
||||
@Test func `ensures launch agent when local and not attach only`() {
|
||||
#expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
@Test func `skips launch agent when native host is preferred`() {
|
||||
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: false))
|
||||
paused: false,
|
||||
defaults: Self.cleanDefaults()))
|
||||
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: true))
|
||||
@@ -21,4 +23,61 @@ struct GatewayAutostartPolicyTests {
|
||||
mode: .remote,
|
||||
paused: false))
|
||||
}
|
||||
|
||||
@Test func `launch agent remains fallback when native host disabled`() {
|
||||
#expect(GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: false,
|
||||
environment: [GatewayNativeHostPolicy.environmentKey: "launchd"]))
|
||||
#expect(!GatewayAutostartPolicy.shouldEnsureLaunchAgent(
|
||||
mode: .local,
|
||||
paused: false,
|
||||
environment: [GatewayNativeHostPolicy.environmentKey: "native"]))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayNativeHostPolicyTests {
|
||||
@Test func `prefers native host for local mode by default`() {
|
||||
let defaults = Self.cleanDefaults()
|
||||
#expect(GatewayNativeHostPolicy.shouldPreferNativeHost(
|
||||
mode: .local,
|
||||
defaults: defaults,
|
||||
environment: [:]))
|
||||
#expect(!GatewayNativeHostPolicy.shouldPreferNativeHost(
|
||||
mode: .remote,
|
||||
defaults: defaults,
|
||||
environment: [:]))
|
||||
#expect(!GatewayNativeHostPolicy.shouldPreferNativeHost(
|
||||
mode: .unconfigured,
|
||||
defaults: defaults,
|
||||
environment: [:]))
|
||||
}
|
||||
|
||||
@Test func `environment can force launchd fallback or native host`() {
|
||||
#expect(!GatewayNativeHostPolicy.shouldPreferNativeHost(
|
||||
mode: .local,
|
||||
environment: [GatewayNativeHostPolicy.environmentKey: "0"]))
|
||||
#expect(!GatewayNativeHostPolicy.shouldPreferNativeHost(
|
||||
mode: .local,
|
||||
environment: [GatewayNativeHostPolicy.environmentKey: "launchd"]))
|
||||
#expect(GatewayNativeHostPolicy.shouldPreferNativeHost(
|
||||
mode: .local,
|
||||
environment: [GatewayNativeHostPolicy.environmentKey: "1"]))
|
||||
#expect(GatewayNativeHostPolicy.shouldPreferNativeHost(
|
||||
mode: .local,
|
||||
environment: [GatewayNativeHostPolicy.environmentKey: "native"]))
|
||||
}
|
||||
}
|
||||
|
||||
private extension GatewayAutostartPolicyTests {
|
||||
static func cleanDefaults() -> UserDefaults {
|
||||
UserDefaults(suiteName: "GatewayAutostartPolicyTests.\(UUID().uuidString)")!
|
||||
}
|
||||
}
|
||||
|
||||
private extension GatewayNativeHostPolicyTests {
|
||||
static func cleanDefaults() -> UserDefaults {
|
||||
UserDefaults(suiteName: "GatewayNativeHostPolicyTests.\(UUID().uuidString)")!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ read_when:
|
||||
title: "macOS app"
|
||||
---
|
||||
|
||||
The macOS app is the **menu‑bar companion** for OpenClaw. It owns permissions,
|
||||
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
|
||||
capabilities to the agent as a node.
|
||||
The macOS app is the **menu-bar companion** for OpenClaw. It owns permissions,
|
||||
manages/attaches to the Gateway locally (native host, launchd, or manual), and
|
||||
exposes macOS capabilities to the agent as a node.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -23,18 +23,47 @@ capabilities to the agent as a node.
|
||||
|
||||
## Local vs remote mode
|
||||
|
||||
- **Local** (default): the app attaches to a running local Gateway if present;
|
||||
otherwise it enables the launchd service via `openclaw gateway install`.
|
||||
- **Local** (default): the app hosts the Gateway as a child process under
|
||||
OpenClaw.app when no compatible Gateway is already running. This gives macOS
|
||||
TCC a native app identity for permission-sensitive desktop features such as
|
||||
Codex Computer Use.
|
||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||
a local process.
|
||||
The app starts the local **node host service** so the remote Gateway can reach this Mac.
|
||||
The app does not spawn the Gateway as a child process.
|
||||
The app does not spawn the Gateway as a child process in remote mode.
|
||||
Gateway discovery now prefers Tailscale MagicDNS names over raw tailnet IPs,
|
||||
so the Mac app recovers more reliably when tailnet IPs change.
|
||||
|
||||
Set `OPENCLAW_MAC_NATIVE_GATEWAY=launchd` before launching the app to use the
|
||||
legacy launchd fallback in local mode. Launchd is still useful for headless
|
||||
Gateway service management, but permission-sensitive Computer Use flows should
|
||||
run through the native-hosted Gateway.
|
||||
|
||||
## Native Gateway host
|
||||
|
||||
In native-host mode, OpenClaw.app starts the same Node Gateway command that the
|
||||
CLI would run, marks the child environment with `OPENCLAW_MAC_NATIVE_HOST=1`,
|
||||
and supervises its logs/readiness from the menu bar. Codex still runs inside the
|
||||
Gateway; OpenClaw.app is the macOS identity and lifecycle parent, not a Codex
|
||||
runtime replacement.
|
||||
|
||||
This shape keeps the runtime architecture stable:
|
||||
|
||||
```text
|
||||
OpenClaw.app native host
|
||||
-> OpenClaw Gateway
|
||||
-> Codex app-server
|
||||
-> Codex Computer Use MCP
|
||||
```
|
||||
|
||||
The app disables its managed launchd Gateway before starting a native-hosted
|
||||
Gateway so the local port does not stay occupied by a background Node lineage.
|
||||
If an unmanaged listener is already on the Gateway port, the app attaches to it
|
||||
and reports that existing instance instead of replacing it.
|
||||
|
||||
## Launchd control
|
||||
|
||||
The app manages a per‑user LaunchAgent labeled `ai.openclaw.gateway`
|
||||
The app can manage a per-user LaunchAgent labeled `ai.openclaw.gateway`
|
||||
(or `ai.openclaw.<profile>` when using `--profile`/`OPENCLAW_PROFILE`; legacy `com.openclaw.*` still unloads).
|
||||
|
||||
```bash
|
||||
@@ -44,8 +73,9 @@ launchctl bootout gui/$UID/ai.openclaw.gateway
|
||||
|
||||
Replace the label with `ai.openclaw.<profile>` when running a named profile.
|
||||
|
||||
If the LaunchAgent isn’t installed, enable it from the app or run
|
||||
`openclaw gateway install`.
|
||||
If the LaunchAgent is not installed, run `openclaw gateway install`. Local mode
|
||||
normally uses the native host first; launchd remains the fallback for headless
|
||||
service installs and explicit `OPENCLAW_MAC_NATIVE_GATEWAY=launchd` sessions.
|
||||
|
||||
## Node capabilities (mac)
|
||||
|
||||
|
||||
@@ -19,6 +19,27 @@ then lets Codex own the native MCP tool calls during Codex-mode turns.
|
||||
Use this page when OpenClaw is already using the native Codex harness. For the
|
||||
runtime setup itself, see [Codex harness](/plugins/codex-harness).
|
||||
|
||||
## macOS native host
|
||||
|
||||
On macOS, Codex Computer Use must run from a Gateway that OpenClaw.app launched
|
||||
in native-host mode. macOS grants Accessibility, Screen Recording, and
|
||||
Automation permissions to a responsible app identity, not to an abstract chat
|
||||
session. A launchd-started Gateway runs from a headless Node lineage, which can
|
||||
leave Codex Computer Use blocked by TCC even when the user already approved the
|
||||
prompts in the app.
|
||||
|
||||
The supported local shape is:
|
||||
|
||||
```text
|
||||
OpenClaw.app native host
|
||||
-> OpenClaw Gateway
|
||||
-> Codex app-server
|
||||
-> Codex Computer Use MCP
|
||||
```
|
||||
|
||||
Codex still lives inside the Gateway. OpenClaw.app only provides the macOS
|
||||
identity and lifecycle parent needed for desktop permissions.
|
||||
|
||||
## OpenClaw.app and Peekaboo
|
||||
|
||||
OpenClaw.app's Peekaboo integration is separate from Codex Computer Use. The
|
||||
@@ -115,6 +136,12 @@ register the bundled Codex marketplace from
|
||||
fails. If setup still cannot make the MCP server available, the turn fails
|
||||
before the thread starts.
|
||||
|
||||
On macOS, setup first verifies that the Gateway was launched by OpenClaw.app in
|
||||
native-host mode. If not, `/codex computer-use status` reports
|
||||
`native_host_missing` and does not contact Codex app-server. Open OpenClaw.app,
|
||||
switch to Local mode, wait for the Gateway to show running, then run
|
||||
`/codex computer-use install` again.
|
||||
|
||||
Existing sessions keep their runtime and Codex thread binding. After changing
|
||||
`agentRuntime` or Computer Use config, use `/new` or `/reset` in the affected
|
||||
chat before testing.
|
||||
@@ -229,6 +256,7 @@ status for chat:
|
||||
| Reason | Meaning | Next step |
|
||||
| ---------------------------- | ------------------------------------------------------ | --------------------------------------------- |
|
||||
| `disabled` | `computerUse.enabled` resolved to false. | Set `enabled` or another Computer Use field. |
|
||||
| `native_host_missing` | macOS Gateway is not hosted by OpenClaw.app. | Start Local mode from OpenClaw.app. |
|
||||
| `marketplace_missing` | No matching marketplace was available. | Configure source, path, or marketplace name. |
|
||||
| `plugin_not_installed` | Marketplace exists, but the plugin is not installed. | Run install or enable `autoInstall`. |
|
||||
| `plugin_disabled` | Plugin is installed but disabled in Codex config. | Run install to re-enable it. |
|
||||
@@ -244,9 +272,10 @@ when available, and the specific message for the failing setup step.
|
||||
## macOS permissions
|
||||
|
||||
Computer Use is macOS-specific. The Codex-owned MCP server may need local OS
|
||||
permissions before it can inspect or control apps. If OpenClaw says Computer Use
|
||||
is installed but the MCP server is unavailable, verify the Codex-side Computer
|
||||
Use setup first:
|
||||
permissions before it can inspect or control apps. If OpenClaw says the native
|
||||
host is missing, fix the Gateway host first. If OpenClaw says Computer Use is
|
||||
installed but the MCP server is unavailable, verify the Codex-side Computer Use
|
||||
setup next:
|
||||
|
||||
- Codex app-server is running on the same host where desktop control should
|
||||
happen.
|
||||
@@ -271,14 +300,19 @@ Codex app-server install writes the plugin config back to enabled.
|
||||
path. Remote-only catalog entries can be inspected but not installed through the
|
||||
current app-server API.
|
||||
|
||||
**Status says native host is required.** Launch OpenClaw.app, use Local mode,
|
||||
and let the app start the Gateway. The launchd fallback is fine for ordinary
|
||||
chat and service use, but Codex Computer Use on macOS needs the native app
|
||||
identity.
|
||||
|
||||
**Status says the MCP server is unavailable.** Re-run install once so MCP
|
||||
servers reload. If it remains unavailable, fix the Codex Computer Use app,
|
||||
Codex app-server MCP status, or macOS permissions.
|
||||
|
||||
**Status or a probe times out on `computer-use.list_apps`.** The plugin and MCP
|
||||
server are present, but the local Computer Use bridge did not answer. Quit or
|
||||
restart Codex Computer Use, relaunch Codex Desktop if needed, then retry in a
|
||||
fresh OpenClaw session.
|
||||
restart Codex Computer Use, confirm OpenClaw.app still owns the local Gateway,
|
||||
then retry in a fresh OpenClaw session.
|
||||
|
||||
**A Computer Use tool says `Native hook relay unavailable`.** The Codex-native
|
||||
tool hook could not reach an active OpenClaw relay through the local bridge or
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ensureCodexComputerUse,
|
||||
installCodexComputerUse,
|
||||
@@ -12,8 +12,13 @@ import {
|
||||
describe("Codex Computer Use setup", () => {
|
||||
const cleanupPaths: string[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("OPENCLAW_MAC_NATIVE_HOST", "1");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
for (const cleanupPath of cleanupPaths.splice(0)) {
|
||||
fs.rmSync(cleanupPath, { recursive: true, force: true });
|
||||
}
|
||||
@@ -32,6 +37,50 @@ describe("Codex Computer Use setup", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("requires the OpenClaw.app native host on macOS before contacting app-server", async () => {
|
||||
const request = vi.fn();
|
||||
|
||||
await expect(
|
||||
readCodexComputerUseStatus({
|
||||
pluginConfig: { computerUse: { enabled: true } },
|
||||
platform: "darwin",
|
||||
env: {},
|
||||
request,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
ready: false,
|
||||
reason: "native_host_missing",
|
||||
installed: false,
|
||||
pluginEnabled: false,
|
||||
mcpServerAvailable: false,
|
||||
message:
|
||||
"Computer Use on macOS requires OpenClaw.app to host the Gateway so macOS can grant desktop-control permissions to the native app. Open OpenClaw.app in Local mode, then run /codex computer-use install again.",
|
||||
}),
|
||||
);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows explicit non-native macOS setup for diagnostics", async () => {
|
||||
const request = createComputerUseRequest({ installed: true });
|
||||
|
||||
await expect(
|
||||
readCodexComputerUseStatus({
|
||||
pluginConfig: { computerUse: { enabled: true, marketplaceName: "desktop-tools" } },
|
||||
platform: "darwin",
|
||||
env: { OPENCLAW_CODEX_COMPUTER_USE_ALLOW_NON_NATIVE_HOST: "1" },
|
||||
request,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ready: true,
|
||||
reason: "ready",
|
||||
message: "Computer Use is ready.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an installed Computer Use MCP server from a registered marketplace", async () => {
|
||||
const request = createComputerUseRequest({ installed: true });
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export type CodexComputerUseRequest = <T = JsonValue | undefined>(
|
||||
|
||||
export type CodexComputerUseStatusReason =
|
||||
| "disabled"
|
||||
| "native_host_missing"
|
||||
| "marketplace_missing"
|
||||
| "plugin_not_installed"
|
||||
| "plugin_disabled"
|
||||
@@ -61,6 +62,8 @@ export type CodexComputerUseSetupParams = {
|
||||
signal?: AbortSignal;
|
||||
forceEnable?: boolean;
|
||||
defaultBundledMarketplacePath?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
};
|
||||
|
||||
type MarketplaceRef =
|
||||
@@ -132,6 +135,9 @@ export async function ensureCodexComputerUse(
|
||||
if (status.ready) {
|
||||
return status;
|
||||
}
|
||||
if (status.reason === "native_host_missing") {
|
||||
throw new CodexComputerUseSetupError(status);
|
||||
}
|
||||
if (config.autoInstall) {
|
||||
const blockedAutoInstallStatus = blockUnsafeAutoInstallStatus(config);
|
||||
if (blockedAutoInstallStatus) {
|
||||
@@ -181,7 +187,18 @@ async function inspectCodexComputerUse(params: {
|
||||
config: ResolvedCodexComputerUseConfig;
|
||||
installPlugin: boolean;
|
||||
defaultBundledMarketplacePath?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
}): Promise<CodexComputerUseStatus> {
|
||||
const nativeHostStatus = macNativeHostUnavailableStatus({
|
||||
config: params.config,
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
});
|
||||
if (nativeHostStatus) {
|
||||
return nativeHostStatus;
|
||||
}
|
||||
|
||||
const request = createComputerUseRequest(params);
|
||||
if (params.installPlugin) {
|
||||
await request<v2.ExperimentalFeatureEnablementSetResponse>(
|
||||
@@ -417,6 +434,37 @@ function blockUnsafeAutoInstallStatus(
|
||||
);
|
||||
}
|
||||
|
||||
function macNativeHostUnavailableStatus(params: {
|
||||
config: ResolvedCodexComputerUseConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
}): CodexComputerUseStatus | undefined {
|
||||
const platform = params.platform ?? process.platform;
|
||||
if (platform !== "darwin") {
|
||||
return undefined;
|
||||
}
|
||||
const env = params.env ?? process.env;
|
||||
if (isTruthyEnvFlag(env.OPENCLAW_MAC_NATIVE_HOST)) {
|
||||
return undefined;
|
||||
}
|
||||
if (isTruthyEnvFlag(env.OPENCLAW_CODEX_COMPUTER_USE_ALLOW_NON_NATIVE_HOST)) {
|
||||
return undefined;
|
||||
}
|
||||
return unavailableStatus(
|
||||
params.config,
|
||||
"native_host_missing",
|
||||
"Computer Use on macOS requires OpenClaw.app to host the Gateway so macOS can grant desktop-control permissions to the native app. Open OpenClaw.app in Local mode, then run /codex computer-use install again.",
|
||||
);
|
||||
}
|
||||
|
||||
function isTruthyEnvFlag(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized !== "" && !["0", "false", "no", "off"].includes(normalized);
|
||||
}
|
||||
|
||||
function shouldAddBundledComputerUseMarketplace(params: {
|
||||
config: ResolvedCodexComputerUseConfig;
|
||||
allowAdd: boolean;
|
||||
|
||||
@@ -91,6 +91,13 @@ export function formatAccount(
|
||||
}
|
||||
|
||||
export function formatComputerUseStatus(status: CodexComputerUseStatus): string {
|
||||
if (status.reason === "native_host_missing") {
|
||||
return [
|
||||
"Computer Use: native host required",
|
||||
`Gateway host: OpenClaw.app required on macOS`,
|
||||
status.message,
|
||||
].join("\n");
|
||||
}
|
||||
const lines = [
|
||||
`Computer Use: ${status.ready ? "ready" : status.enabled ? "not ready" : "disabled"}`,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user