feat: route macOS node exec via app IPC
This commit is contained in:
35
CHANGELOG.md
35
CHANGELOG.md
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
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
|
## 2026.1.18-4
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
@@ -24,11 +29,6 @@ Docs: https://docs.clawd.bot
|
|||||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
- 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)
|
- 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
|
## 2026.1.18-3
|
||||||
|
|
||||||
### Changes
|
### 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: 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
|
- 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.
|
- 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
|
### Fixes
|
||||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
- 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
|
### Fixes
|
||||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
- 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
|
## 2026.1.17-6
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
|
import CryptoKit
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
@@ -27,6 +28,49 @@ private struct ExecApprovalSocketDecision: Codable {
|
|||||||
var decision: ExecApprovalDecision
|
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 {
|
enum ExecApprovalsSocketClient {
|
||||||
private struct TimeoutError: LocalizedError {
|
private struct TimeoutError: LocalizedError {
|
||||||
var message: String
|
var message: String
|
||||||
@@ -146,6 +190,9 @@ final class ExecApprovalsPromptServer {
|
|||||||
token: approvals.token,
|
token: approvals.token,
|
||||||
onPrompt: { request in
|
onPrompt: { request in
|
||||||
await ExecApprovalsPromptPresenter.prompt(request)
|
await ExecApprovalsPromptPresenter.prompt(request)
|
||||||
|
},
|
||||||
|
onExec: { request in
|
||||||
|
await ExecHostExecutor.handle(request)
|
||||||
})
|
})
|
||||||
server.start()
|
server.start()
|
||||||
self.server = server
|
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 final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
||||||
private let socketPath: String
|
private let socketPath: String
|
||||||
private let token: String
|
private let token: String
|
||||||
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
||||||
|
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
|
||||||
private var socketFD: Int32 = -1
|
private var socketFD: Int32 = -1
|
||||||
private var acceptTask: Task<Void, Never>?
|
private var acceptTask: Task<Void, Never>?
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
@@ -218,11 +436,13 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
init(
|
init(
|
||||||
socketPath: String,
|
socketPath: String,
|
||||||
token: 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.socketPath = socketPath
|
||||||
self.token = token
|
self.token = token
|
||||||
self.onPrompt = onPrompt
|
self.onPrompt = onPrompt
|
||||||
|
self.onExec = onExec
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -317,26 +537,39 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
private func handleClient(fd: Int32) async {
|
private func handleClient(fd: Int32) async {
|
||||||
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
do {
|
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),
|
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||||
let data = line.data(using: .utf8)
|
let data = line.data(using: .utf8)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
guard
|
||||||
guard request.type == "request", request.token == self.token else {
|
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
|
let type = envelope["type"] as? String
|
||||||
let data = try JSONEncoder().encode(response)
|
else {
|
||||||
var payload = data
|
return
|
||||||
payload.append(0x0A)
|
}
|
||||||
try handle.write(contentsOf: payload)
|
|
||||||
|
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
|
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 {
|
} catch {
|
||||||
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
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)
|
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||||
return String(data: lineData, encoding: .utf8)
|
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
109
src/infra/exec-host.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ import {
|
|||||||
saveExecApprovals,
|
saveExecApprovals,
|
||||||
type ExecApprovalsFile,
|
type ExecApprovalsFile,
|
||||||
} from "../infra/exec-approvals.js";
|
} 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 { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
|
|
||||||
@@ -86,6 +92,9 @@ type ExecEventPayload = {
|
|||||||
const OUTPUT_CAP = 200_000;
|
const OUTPUT_CAP = 200_000;
|
||||||
const OUTPUT_EVENT_TAIL = 20_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([
|
const blockedEnvKeys = new Set([
|
||||||
"PATH",
|
"PATH",
|
||||||
"NODE_OPTIONS",
|
"NODE_OPTIONS",
|
||||||
@@ -305,6 +314,18 @@ function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload {
|
|||||||
return { ...payload, output: text };
|
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> {
|
export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
||||||
const config = await ensureNodeHostConfig();
|
const config = await ensureNodeHostConfig();
|
||||||
const nodeId = opts.nodeId?.trim() || config.nodeId;
|
const nodeId = opts.nodeId?.trim() || config.nodeId;
|
||||||
@@ -555,6 +576,87 @@ async function handleInvoke(
|
|||||||
const skillAllow =
|
const skillAllow =
|
||||||
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
|
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") {
|
if (security === "deny") {
|
||||||
client.sendEvent(
|
client.sendEvent(
|
||||||
"exec.denied",
|
"exec.denied",
|
||||||
|
|||||||
Reference in New Issue
Block a user