diff --git a/CHANGELOG.md b/CHANGELOG.md index f260f9a59..d5e8a9892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift index f6bd40bef..fa636b718 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift @@ -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 = [ + "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? 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.. 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.authenticationCode(for: Data(message.utf8), using: key) + return mac.map { String(format: "%02x", $0) }.joined() + } } diff --git a/src/infra/exec-host.ts b/src/infra/exec-host.ts new file mode 100644 index 000000000..9a748bf0b --- /dev/null +++ b/src/infra/exec-host.ts @@ -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 | 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 { + 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 + } + } + }); + }); +} diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 7cc5f7e5d..6bf8454ab 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -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; + request: ExecHostRequest; +}): Promise { + const { approvals, request } = params; + return await requestExecHostViaSocket({ + socketPath: approvals.socketPath, + token: approvals.token, + request, + }); +} + export async function runNodeHost(opts: NodeHostRunOptions): Promise { 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",