diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 5a4806896699..0258888b87e0 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -186,9 +186,6 @@ actor MacNodeRuntime { /// One branch per advertised native command keeps command ownership explicit. func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { let command = req.command - if let nodeHostWorker, await nodeHostWorker.supports(command) { - return await nodeHostWorker.invoke(req) - } if self.isCanvasCommand(command), !Self.canvasEnabled() { return BridgeInvokeResponse( id: req.id, @@ -230,6 +227,9 @@ actor MacNodeRuntime { MacNodeClaudeSessionCatalogContract.readCommand: return try await self.handleClaudeSessionInvoke(req) default: + if let nodeHostWorker, await nodeHostWorker.supports(command) { + return await nodeHostWorker.invoke(req) + } return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") } } catch let error as MacNodeCodexThreadCatalog.CatalogError { diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift index cc34f00b2f71..a9e72d39de16 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift @@ -6,13 +6,17 @@ import Testing private struct WorkerBackpressureTimeout: Error {} private actor StubMacNodeHostWorker: MacNodeHostWorking { - let manifest = MacNodeHostManifest( - version: "test", - caps: ["system", "mcp"], - commands: ["system.run", "mcp.tools.call.v1"], - pathEnv: "/usr/bin:/bin") + let manifest: MacNodeHostManifest private var requests: [BridgeInvokeRequest] = [] + init(commands: [String] = ["system.run", "mcp.tools.call.v1"]) { + self.manifest = MacNodeHostManifest( + version: "test", + caps: ["system", "mcp"], + commands: commands, + pathEnv: "/usr/bin:/bin") + } + func start(command _: [String]) async throws -> MacNodeHostManifest { self.manifest } func supports(_ command: String) async -> Bool { self.manifest.commands.contains(command) } @@ -90,18 +94,108 @@ struct MacNodeHostWorkerTests { await worker.stop() } - @Test func `Mac runtime forwards CLI node commands to the shared worker`() async { - let worker = StubMacNodeHostWorker() + @Test(arguments: [ + OpenClawSystemCommand.run.rawValue, + "mcp.tools.call.v1", + "codex.terminal.resume.v1", + ]) + func `Mac runtime forwards worker-owned commands to the shared worker`(command: String) async { + let worker = StubMacNodeHostWorker(commands: [command]) let runtime = MacNodeRuntime(nodeHostWorker: worker) let response = await runtime.handleInvoke(BridgeInvokeRequest( id: "worker-run", - command: OpenClawSystemCommand.run.rawValue, + command: command, paramsJSON: #"{"command":["/usr/bin/true"]}"#)) #expect(response.ok) #expect(response.payloadJSON == #"{"owner":"cli"}"#) - #expect(await worker.invokedCommands() == [OpenClawSystemCommand.run.rawValue]) + #expect(await worker.invokedCommands() == [command]) + } + + @Test(arguments: [ + MacNodeCodexThreadCatalogContract.listCommand, + MacNodeCodexThreadCatalogContract.turnsCommand, + MacNodeClaudeSessionCatalogContract.listCommand, + MacNodeClaudeSessionCatalogContract.readCommand, + ]) + func `native session catalogs own commands shared with the worker`(command: String) async { + let worker = StubMacNodeHostWorker(commands: [command]) + let payload = #"{"owner":"native"}"# + let runtime = MacNodeRuntime( + nodeHostWorker: worker, + codexThreadCatalogEnabled: { true }, + codexThreadListRequest: { _ in payload }, + codexThreadTurnsRequest: { _ in payload }, + claudeSessionCatalogEnabled: { true }, + claudeSessionListRequest: { _ in payload }, + claudeSessionReadRequest: { _ in payload }) + + let response = await runtime.handleInvoke(BridgeInvokeRequest( + id: "native-catalog", + command: command, + paramsJSON: #"{"limit":1}"#)) + + #expect(response.ok) + #expect(response.payloadJSON == payload) + #expect(await worker.invokedCommands().isEmpty) + } + + @Test(arguments: [ + ( + MacNodeCodexThreadCatalogContract.listCommand, + "UNAVAILABLE: Codex session catalog is disabled" + ), + ( + MacNodeCodexThreadCatalogContract.turnsCommand, + "UNAVAILABLE: Codex session catalog is disabled" + ), + ( + MacNodeClaudeSessionCatalogContract.listCommand, + "UNAVAILABLE: Claude session catalog is disabled" + ), + ( + MacNodeClaudeSessionCatalogContract.readCommand, + "UNAVAILABLE: Claude session catalog is disabled" + ), + ( + OpenClawComputerCommand.act.rawValue, + "COMPUTER_DISABLED: enable Computer Control in Settings" + ), + ]) + func `worker cannot bypass native capability consent`(command: String, expectedMessage: String) async { + let worker = StubMacNodeHostWorker(commands: [command]) + let runtime = MacNodeRuntime( + nodeHostWorker: worker, + computerControlEnabled: { false }, + codexThreadCatalogEnabled: { false }, + claudeSessionCatalogEnabled: { false }) + + let response = await runtime.handleInvoke(BridgeInvokeRequest( + id: "native-disabled", + command: command)) + + #expect(!response.ok) + #expect(response.error?.code == .unavailable) + #expect(response.error?.message == expectedMessage) + #expect(await worker.invokedCommands().isEmpty) + } + + @Test(arguments: [OpenClawCanvasCommand.present.rawValue, "canvas.plugin.render"]) + func `worker cannot bypass the canvas namespace consent gate`(command: String) async { + await TestIsolation.withUserDefaultsValues([canvasEnabledKey: false]) { + let worker = StubMacNodeHostWorker(commands: [command]) + let runtime = MacNodeRuntime(nodeHostWorker: worker) + + let response = await runtime.handleInvoke(BridgeInvokeRequest( + id: "canvas-disabled", + command: command)) + + #expect(!response.ok) + #expect(response.error?.code == .unavailable) + #expect(response.error?.message == "CANVAS_DISABLED: enable Canvas in Settings") + #expect(await worker.invokedCommands().isEmpty) + } } @Test func `capability union preserves native order and adds worker commands once`() {