Compare commits

...

2 Commits

Author SHA1 Message Date
pashpashpash
02ff01c3c5 fix: avoid launchd gateway reuse in native mode 2026-04-29 18:48:45 -04:00
pashpashpash
bc94742ccb feat: host mac gateway for computer use 2026-04-29 18:44:17 -04:00
10 changed files with 449 additions and 20 deletions

View File

@@ -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"

View File

@@ -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)
}
}

View 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()
}
}

View File

@@ -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 {

View File

@@ -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)")!
}
}

View File

@@ -6,9 +6,9 @@ read_when:
title: "macOS app"
---
The macOS app is the **menubar 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 peruser 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 isnt 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)

View File

@@ -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

View File

@@ -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 });

View File

@@ -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;

View File

@@ -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"}`,
];