feat: route macOS node exec via app IPC

This commit is contained in:
Peter Steinberger
2026-01-18 16:36:13 +00:00
parent 5fe3c36471
commit 081123c0e4
4 changed files with 561 additions and 19 deletions

View File

@@ -2,6 +2,11 @@
Docs: https://docs.clawd.bot
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
## 2026.1.18-4
### Changes
@@ -24,11 +29,6 @@ Docs: https://docs.clawd.bot
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
## 2026.1.18-3
### Changes
@@ -45,6 +45,14 @@ Docs: https://docs.clawd.bot
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
### Fixes
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
@@ -57,6 +65,23 @@ Docs: https://docs.clawd.bot
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-6
### Changes

View File

@@ -1,5 +1,6 @@
import AppKit
import ClawdbotKit
import CryptoKit
import Darwin
import Foundation
import OSLog
@@ -27,6 +28,49 @@ private struct ExecApprovalSocketDecision: Codable {
var decision: ExecApprovalDecision
}
private struct ExecHostSocketRequest: Codable {
var type: String
var id: String
var nonce: String
var ts: Int
var hmac: String
var requestJson: String
}
private struct ExecHostRequest: Codable {
var command: [String]
var rawCommand: String?
var cwd: String?
var env: [String: String]?
var timeoutMs: Int?
var needsScreenRecording: Bool?
var agentId: String?
var sessionKey: String?
}
private struct ExecHostRunResult: Codable {
var exitCode: Int?
var timedOut: Bool
var success: Bool
var stdout: String
var stderr: String
var error: String?
}
private struct ExecHostError: Codable {
var code: String
var message: String
var reason: String?
}
private struct ExecHostResponse: Codable {
var type: String
var id: String
var ok: Bool
var payload: ExecHostRunResult?
var error: ExecHostError?
}
enum ExecApprovalsSocketClient {
private struct TimeoutError: LocalizedError {
var message: String
@@ -146,6 +190,9 @@ final class ExecApprovalsPromptServer {
token: approvals.token,
onPrompt: { request in
await ExecApprovalsPromptPresenter.prompt(request)
},
onExec: { request in
await ExecHostExecutor.handle(request)
})
server.start()
self.server = server
@@ -206,11 +253,182 @@ enum ExecApprovalsPromptPresenter {
}
}
@MainActor
enum ExecHostExecutor {
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYOPT",
]
private static let blockedEnvPrefixes: [String] = [
"DYLD_",
"LD_",
]
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !command.isEmpty else {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
}
let displayCommand = ExecCommandFormatter.displayString(
for: command,
rawCommand: request.rawCommand)
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
let security = approvals.agent.security
let ask = approvals.agent.ask
let autoAllowSkills = approvals.agent.autoAllowSkills
let env = self.sanitizedEnv(request.env)
let resolution = ExecCommandResolution.resolve(
command: command,
rawCommand: request.rawCommand,
cwd: request.cwd,
env: env)
let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil
let skillAllow: Bool
if autoAllowSkills, let name = resolution?.executableName {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = bins.contains(name)
} else {
skillAllow = false
}
if security == .deny {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny", reason: "security=deny"))
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
return false
}()
var approvedByAsk = false
if requiresAsk {
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
cwd: request.cwd,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: trimmedAgent,
resolvedPath: resolution?.resolvedPath))
switch decision {
case .deny:
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied", reason: "user-denied"))
case .allowAlways:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
case .allowOnce:
approvedByAsk = true
}
}
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss", reason: "allowlist-miss"))
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: trimmedAgent,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolution?.resolvedPath)
}
if request.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording", reason: "permission:screenRecording"))
}
}
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await Task.detached { () -> ShellExecutor.ShellResult in
await ShellExecutor.runDetailed(
command: command,
cwd: request.cwd,
env: env,
timeout: timeoutSec)
}.value
let payload = ExecHostRunResult(
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage)
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: true,
payload: payload,
error: nil)
}
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
guard let overrides else { return nil }
var merged = ProcessInfo.processInfo.environment
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()
if self.blockedEnvKeys.contains(upper) { continue }
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
merged[key] = value
}
return merged
}
}
private final class ExecApprovalsSocketServer: @unchecked Sendable {
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
private let socketPath: String
private let token: String
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
private var socketFD: Int32 = -1
private var acceptTask: Task<Void, Never>?
private var isRunning = false
@@ -218,11 +436,13 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
init(
socketPath: String,
token: String,
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
{
self.socketPath = socketPath
self.token = token
self.onPrompt = onPrompt
self.onExec = onExec
}
func start() {
@@ -317,26 +537,39 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
private func handleClient(fd: Int32) async {
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
do {
guard self.isAllowedPeer(fd: fd) else {
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
return
}
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
let data = line.data(using: .utf8)
else {
return
}
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
guard request.type == "request", request.token == self.token else {
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
guard
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let type = envelope["type"] as? String
else {
return
}
if type == "request" {
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
guard request.token == self.token else {
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
return
}
let decision = await self.onPrompt(request.request)
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
return
}
if type == "exec" {
let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data)
let response = await self.handleExecRequest(request)
try self.sendExecResponse(handle: handle, response: response)
return
}
let decision = await self.onPrompt(request.request)
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
let responseData = try JSONEncoder().encode(response)
var payload = responseData
payload.append(0x0A)
try handle.write(contentsOf: payload)
} catch {
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
}
@@ -357,4 +590,77 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8)
}
private func sendApprovalResponse(
handle: FileHandle,
id: String,
decision: ExecApprovalDecision) throws
{
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
}
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
}
private func isAllowedPeer(fd: Int32) -> Bool {
var uid = uid_t(0)
var gid = gid_t(0)
if getpeereid(fd, &uid, &gid) != 0 {
return false
}
return uid == geteuid()
}
private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse {
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
if abs(nowMs - request.ts) > 10_000 {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
}
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
if expected != request.hmac {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac"))
}
guard let requestData = request.requestJson.data(using: .utf8),
let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData)
else {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json"))
}
let response = await self.onExec(payload)
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: response.ok,
payload: response.payload,
error: response.error)
}
private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String {
let key = SymmetricKey(data: Data(self.token.utf8))
let message = "\(nonce):\(ts):\(requestJson)"
let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
return mac.map { String(format: "%02x", $0) }.joined()
}
}

109
src/infra/exec-host.ts Normal file
View File

@@ -0,0 +1,109 @@
import crypto from "node:crypto";
import net from "node:net";
export type ExecHostRequest = {
command: string[];
rawCommand?: string | null;
cwd?: string | null;
env?: Record<string, string> | null;
timeoutMs?: number | null;
needsScreenRecording?: boolean | null;
agentId?: string | null;
sessionKey?: string | null;
};
export type ExecHostRunResult = {
exitCode?: number;
timedOut: boolean;
success: boolean;
stdout: string;
stderr: string;
error?: string | null;
};
export type ExecHostError = {
code: string;
message: string;
reason?: string;
};
export type ExecHostResponse =
| { ok: true; payload: ExecHostRunResult }
| { ok: false; error: ExecHostError };
export async function requestExecHostViaSocket(params: {
socketPath: string;
token: string;
request: ExecHostRequest;
timeoutMs?: number;
}): Promise<ExecHostResponse | null> {
const { socketPath, token, request } = params;
if (!socketPath || !token) return null;
const timeoutMs = params.timeoutMs ?? 20_000;
return await new Promise((resolve) => {
const client = new net.Socket();
let settled = false;
let buffer = "";
const finish = (value: ExecHostResponse | null) => {
if (settled) return;
settled = true;
try {
client.destroy();
} catch {
// ignore
}
resolve(value);
};
const requestJson = JSON.stringify(request);
const nonce = crypto.randomBytes(16).toString("hex");
const ts = Date.now();
const hmac = crypto
.createHmac("sha256", token)
.update(`${nonce}:${ts}:${requestJson}`)
.digest("hex");
const payload = JSON.stringify({
type: "exec",
id: crypto.randomUUID(),
nonce,
ts,
hmac,
requestJson,
});
const timer = setTimeout(() => finish(null), timeoutMs);
client.on("error", () => finish(null));
client.connect(socketPath, () => {
client.write(`${payload}\n`);
});
client.on("data", (data) => {
buffer += data.toString("utf8");
let idx = buffer.indexOf("\n");
while (idx !== -1) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
idx = buffer.indexOf("\n");
if (!line) continue;
try {
const msg = JSON.parse(line) as { type?: string; ok?: boolean; payload?: unknown; error?: unknown };
if (msg?.type === "exec-res") {
clearTimeout(timer);
if (msg.ok === true && msg.payload) {
finish({ ok: true, payload: msg.payload as ExecHostRunResult });
return;
}
if (msg.ok === false && msg.error) {
finish({ ok: false, error: msg.error as ExecHostError });
return;
}
finish(null);
return;
}
} catch {
// ignore
}
}
});
});
}

View File

@@ -19,6 +19,12 @@ import {
saveExecApprovals,
type ExecApprovalsFile,
} from "../infra/exec-approvals.js";
import {
requestExecHostViaSocket,
type ExecHostRequest,
type ExecHostResponse,
type ExecHostRunResult,
} from "../infra/exec-host.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { VERSION } from "../version.js";
@@ -86,6 +92,9 @@ type ExecEventPayload = {
const OUTPUT_CAP = 200_000;
const OUTPUT_EVENT_TAIL = 20_000;
const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
const execHostFallbackAllowed = process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim() === "1";
const blockedEnvKeys = new Set([
"PATH",
"NODE_OPTIONS",
@@ -305,6 +314,18 @@ function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload {
return { ...payload, output: text };
}
async function runViaMacAppExecHost(params: {
approvals: ReturnType<typeof resolveExecApprovals>;
request: ExecHostRequest;
}): Promise<ExecHostResponse | null> {
const { approvals, request } = params;
return await requestExecHostViaSocket({
socketPath: approvals.socketPath,
token: approvals.token,
request,
});
}
export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const config = await ensureNodeHostConfig();
const nodeId = opts.nodeId?.trim() || config.nodeId;
@@ -555,6 +576,87 @@ async function handleInvoke(
const skillAllow =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
const useMacAppExec = process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
if (useMacAppExec) {
const execRequest: ExecHostRequest = {
command: argv,
rawCommand: rawCommand || null,
cwd: params.cwd ?? null,
env: params.env ?? null,
timeoutMs: params.timeoutMs ?? null,
needsScreenRecording: params.needsScreenRecording ?? null,
agentId: agentId ?? null,
sessionKey: sessionKey ?? null,
};
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
if (!response) {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "companion-unavailable",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: {
code: "UNAVAILABLE",
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
},
});
return;
}
if (!response.ok) {
const reason = response.error.reason ?? "approval-required";
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason,
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: response.error.message },
});
return;
}
const result: ExecHostRunResult = response.payload;
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
client.sendEvent(
"exec.finished",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: combined,
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: true,
payloadJSON: JSON.stringify(result),
});
return;
}
if (security === "deny") {
client.sendEvent(
"exec.denied",