Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ jobs:
- name: Compile-check all targets (debug)
run: swift build

- name: Unit tests
run: swift test

- name: Package "AhaKey Studio.app" (release, ad-hoc signed)
run: |
chmod +x scripts/package_app.sh
Expand Down
5 changes: 5 additions & 0 deletions ahakeyconfig-mac/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,10 @@ let package = Package(
name: "AhaKeyConfigAgent",
path: "Sources/Agent"
),
.testTarget(
name: "AhaKeyConfigTests",
dependencies: ["AhaKeyConfig", "AhaKeyConfigAgent"],
path: "Tests/AhaKeyConfigTests"
),
]
)
43 changes: 26 additions & 17 deletions ahakeyconfig-mac/Sources/Agent/AhaKeyAgent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat

var onLog: ((String) -> Void)?

init(socketPath: String = "/tmp/ahakey.sock") {
init(socketPath: String = AhaKeySocket.defaultPath) {
self.socketPath = socketPath
if let raw = UserDefaults.standard.object(forKey: Self.switchOverrideDefaultsKey) as? Int {
userSwitchOverride = UInt8(clamping: raw)
Expand Down Expand Up @@ -198,35 +198,51 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat
}

startWatchdog()

do {
try AhaKeySocket.prepareDirectory()
} catch {
emit("socket 目录准备失败: \(error.localizedDescription)")
return false
}

// 清理没有监听进程的残留 socket
unlink(socketPath)

let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { emit("socket() 失败"); return false }

var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
socketPath.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in
let buf = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self)
strcpy(buf, ptr)
}
guard var addr = AhaKeySocket.makeAddress(path: socketPath) else {
emit("socket 路径超出 sockaddr_un 容量: \(socketPath)")
close(fd)
return false
}

// 权限由 bind 时的 umask 决定,事后 chmod 会留下一个宽松窗口,所以两者都做。
let previousMask = umask(0o177)
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
bind(fd, sockPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
umask(previousMask)
guard bindResult == 0 else { emit("bind() 失败: \(errno)"); close(fd); return false }
if chmod(socketPath, 0o600) != 0 {
emit("chmod 0600 失败: \(errno)(socket 可能对其他用户可见)")
}

listen(fd, 5)
emit("监听 Unix socket: \(socketPath)")
emit("监听 Unix socket: \(socketPath)(0600,仅本用户)")

DispatchQueue.global(qos: .utility).async { [weak self] in
while true {
let clientFd = accept(fd, nil, nil)
guard clientFd >= 0 else { continue }
guard AhaKeySocket.peerIsSameUser(clientFd) else {
close(clientFd)
self?.emit("拒绝连接:对端不是本用户")
continue
}
self?.handleClient(clientFd)
}
}
Expand Down Expand Up @@ -264,14 +280,7 @@ final class AhaKeyAgent: NSObject, CBCentralManagerDelegate, CBPeripheralDelegat
guard fd >= 0 else { return false }
defer { close(fd) }

var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in
let buf = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self)
strcpy(buf, ptr)
}
}
guard var addr = AhaKeySocket.makeAddress(path: path) else { return false }

return withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
Expand Down
49 changes: 49 additions & 0 deletions ahakeyconfig-mac/Sources/Agent/AhaKeySocket.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation

/// Agent 与 hook 客户端之间 Unix socket 的路径与权限。
///
/// 不放 `/tmp`:该目录全局可写,其他用户可以在 agent 启动前抢先 bind 同名路径冒充它
/// (`AhaKeyAgent.startSocketListener` 见到已有监听会主动让位),对每次审批请求回自动批准。
/// 同 uid 的进程仍连得上,Unix 权限位管不了这层。
enum AhaKeySocket {
static var defaultPath: String {
directoryURL.appendingPathComponent("agent.sock").path
}

static var directoryURL: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/AhaKeyConfig", isDirectory: true)
}

/// 建目录并置为 0700,已存在时也改一次。
static func prepareDirectory() throws {
try FileManager.default.createDirectory(
at: directoryURL,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directoryURL.path)
}

static func peerIsSameUser(_ fd: Int32) -> Bool {
var uid = uid_t(0)
var gid = gid_t(0)
guard getpeereid(fd, &uid, &gid) == 0 else { return false }
return uid == getuid()
}

/// 填充 `sockaddr_un`,路径放不下时返回 nil。
///
/// `sun_path` 只有 104 字节,而这条路径含用户主目录,长度随用户名变化。
static func makeAddress(path: String) -> sockaddr_un? {
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
guard path.utf8.count < capacity else { return nil }
withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in
let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self)
path.withCString { _ = strlcpy(dst, $0, capacity) }
}
return addr
}
}
13 changes: 3 additions & 10 deletions ahakeyconfig-mac/Sources/Agent/HookSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

enum HookSupport {
static let permissionLedValue: UInt8 = 1
static let socketPath = "/tmp/ahakey.sock"
static var socketPath: String { AhaKeySocket.defaultPath }
static let stateRequestTimeout: Double = 2.0
static let permissionRequestTimeout: Double = 5.0

Expand Down Expand Up @@ -42,14 +42,7 @@ enum HookSupport {
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))

var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
socketPath.withCString { src in
withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in
let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self)
_ = strcpy(dst, src)
}
}
guard var addr = AhaKeySocket.makeAddress(path: socketPath) else { return nil }
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
let connected = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Expand Down Expand Up @@ -97,7 +90,7 @@ enum HookSupport {
switchState: Int?
) {
if switchState == nil, reply == nil {
let msg = "[ahakey-hook] \(ide) \(hookName): agent 无回包或 Unix socket 失败(/tmp/ahakey.sock 连不上/超时,超时 \(Int(permissionRequestTimeout))s)。"
let msg = "[ahakey-hook] \(ide) \(hookName): agent 无回包或 Unix socket 失败(\(socketPath) 连不上/超时,超时 \(Int(permissionRequestTimeout))s)。"
+ "请确认 LaunchAgent 里 ahakeyconfig-agent 在跑、且蓝牙已选「由 Agent 占用」并连上键盘。\n"
FileHandle.standardError.write(Data(msg.utf8))
} else if switchState == nil, reply != nil {
Expand Down
4 changes: 2 additions & 2 deletions ahakeyconfig-mac/Sources/Agent/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Foundation
//
// 两种运行模式(由首个参数决定):
// 1. Daemon(无参数 / 只传 --socket):常驻 LaunchAgent,维持 BLE 连接 + 监听 Unix socket
// ahakeyconfig-agent [--socket /tmp/ahakey.sock]
// ahakeyconfig-agent [--socket <path>] // 缺省见 AhaKeySocket.defaultPath
// 2. Hook 子命令(首个参数为 hook):Claude Code / Cursor / Codex / Kimi Code CLI 会 exec 本进程
// ahakeyconfig-agent hook <EventName>
// 内部通过 Unix socket 联系常驻 daemon,并按需向 stdout 输出 Claude 决策 JSON。
Expand All @@ -21,7 +21,7 @@ let socketPath: String
if let idx = args.firstIndex(of: "--socket"), idx + 1 < args.count {
socketPath = args[idx + 1]
} else {
socketPath = "/tmp/ahakey.sock"
socketPath = AhaKeySocket.defaultPath
}

let agent = AhaKeyAgent(socketPath: socketPath)
Expand Down
33 changes: 22 additions & 11 deletions ahakeyconfig-mac/Sources/AhaKeyPluginKit/PluginHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Foundation
// 当前最小三件套:
// - host/getInfo → 返回宿主 app 元信息(bundleID / version / build / platform)
// - host/log → 插件把日志打到宿主 stderr
// - host/getSwitchState → 通过 /tmp/ahakey.sock 问 daemon 拨杆状态(agent 没跑则返回 null)
// - host/getSwitchState → 通过 agent 的 Unix socket 问 daemon 拨杆状态(agent 没跑则返回 null)
//
// 后续要加的(如 host/showNotification、host/openURL、host/storage/*)按相同方式接到
// `registerDefaultHandlers` 即可。增加新方法时记得在 manifest 的权限白名单里同步声明。
Expand Down Expand Up @@ -125,14 +125,16 @@ enum HostLog {

// MARK: - host/getSwitchState

/// 与 `Agent/HookClient.swift` 走同一套 `/tmp/ahakey.sock` 协议
/// 与 `Agent/HookClient.swift` 走同一套 socket 协议
/// (`{"cmd":"permission","value":1}` → `{"switchState": Int, ...}`)。
/// agent 没跑或 BLE 没连上时返回 nil。
///
/// 没把 socket 协议抽成共用 util,是因为 Agent target 与 AhaKeyPluginKit 暂不互相依赖;
/// 后续若多处都要用,再抽 `AhaKeyAgentBridge` library。
/// 在那之前,路径必须与 `Agent/AhaKeySocket.defaultPath` 保持一致。
enum HostAgentBridge {
static let socketPath = "/tmp/ahakey.sock"
static let socketPath = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/AhaKeyConfig/agent.sock").path
static let timeout: Double = 2.0

static func readSwitchState() -> Int? {
Expand All @@ -152,14 +154,7 @@ enum HostAgentBridge {
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))

var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
socketPath.withCString { src in
withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in
let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self)
_ = strcpy(dst, src)
}
}
guard var addr = makeUnixAddress(path: socketPath) else { return nil }
let len = socklen_t(MemoryLayout<sockaddr_un>.size)
let connected = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, len) }
Expand All @@ -180,3 +175,19 @@ enum HostAgentBridge {
return (try? JSONSerialization.jsonObject(with: Data(buf[0 ..< Int(n)]))) as? [String: Any]
}
}

/// 填充 `sockaddr_un`,路径放不下时返回 nil。
///
/// `sun_path` 只有 104 字节,而 socket 路径含用户主目录,长度随用户名变化。
/// 与 `Agent/AhaKeySocket.makeAddress` 同一份逻辑,两个 target 不共享源码。
private func makeUnixAddress(path: String) -> sockaddr_un? {
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
guard path.utf8.count < capacity else { return nil }
withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in
let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self)
path.withCString { _ = strlcpy(dst, $0, capacity) }
}
return addr
}
44 changes: 26 additions & 18 deletions ahakeyconfig-mac/Sources/Utilities/AgentManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ final class AgentManager: ObservableObject {
@Published private(set) var isAgentOperationInProgress = false

private let label = "lab.jawa.ahakeyconfig.agent"
private let socketPath = "/tmp/ahakey.sock"

/// 必须与 Agent target 的 `AhaKeySocket.defaultPath` 一致(两个 target 不共享源码)。
private let socketPath = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/AhaKeyConfig/agent.sock").path

private var launchAgentsDirectoryURL: URL {
FileManager.default.homeDirectoryForCurrentUser
Expand Down Expand Up @@ -200,13 +203,7 @@ final class AgentManager: ObservableObject {
var tv = timeval(tv_sec: 2, tv_usec: 0)
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
socketPath.withCString { src in
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
_ = strcpy(UnsafeMutableRawPointer(dst).assumingMemoryBound(to: CChar.self), src)
}
}
guard var addr = makeUnixAddress(path: socketPath) else { return }
let ok = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
Expand Down Expand Up @@ -236,13 +233,7 @@ final class AgentManager: ObservableObject {
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))

var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
socketPath.withCString { src in
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
_ = strcpy(UnsafeMutableRawPointer(dst).assumingMemoryBound(to: CChar.self), src)
}
}
guard var addr = makeUnixAddress(path: socketPath) else { return false }
let ok = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) {
connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
Expand Down Expand Up @@ -552,7 +543,7 @@ final class AgentManager: ObservableObject {
self.isAgentOperationInProgress = false
self.refresh()
if !self.isRunning {
var m = "已执行 launchctl load / start,但尚未检测到 Agent 在运行(未出现 /tmp/ahakey.sock)。\n\n"
var m = "已执行 launchctl load / start,但尚未检测到 Agent 在运行(未出现 \(socketPath))。\n\n"
if !loadRes.ok && !isBenignLaunchctlLoadMessage(loadRes.mergedOutput) {
m += "load:\n\(loadRes.mergedOutput.isEmpty ? "(无输出)" : loadRes.mergedOutput)\n\n"
}
Expand Down Expand Up @@ -1384,12 +1375,12 @@ final class AgentManager: ObservableObject {
}

private func patchKimiApprovalPy(atPath path: String) throws -> KimiCliPatchStatus {
let marker = "_AHAKEY_SOCKET_PATH = \"/tmp/ahakey.sock\""
let marker = "_AHAKEY_SOCKET_PATH = os.path.expanduser("
let helperAnchor = "type Response = Literal[\"approve\", \"approve_for_session\", \"reject\"]\n"
let helperBlock = """
type Response = Literal["approve", "approve_for_session", "reject"]

_AHAKEY_SOCKET_PATH = "/tmp/ahakey.sock"
_AHAKEY_SOCKET_PATH = os.path.expanduser("~/Library/Application Support/AhaKeyConfig/agent.sock")
_AHAKEY_APPROVAL_CACHE_TTL_S = 0.35
_ahakey_cache_at = 0.0
_ahakey_cache_value: dict[str, object] | None = None
Expand Down Expand Up @@ -1445,6 +1436,7 @@ final class AgentManager: ObservableObject {
let oldImports = "import uuid\n"
let newImports = """
import json
import os
import socket
import time
import uuid
Expand Down Expand Up @@ -1782,3 +1774,19 @@ final class AgentManager: ObservableObject {
runLaunchctlDetailed(args).ok
}
}

/// 填充 `sockaddr_un`,路径放不下时返回 nil。
///
/// `sun_path` 只有 104 字节,而 socket 路径含用户主目录,长度随用户名变化。
/// 与 `Agent/AhaKeySocket.makeAddress` 同一份逻辑,两个 target 不共享源码。
private func makeUnixAddress(path: String) -> sockaddr_un? {
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let capacity = MemoryLayout.size(ofValue: addr.sun_path)
guard path.utf8.count < capacity else { return nil }
withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in
let dst = UnsafeMutableRawPointer(sunPath).assumingMemoryBound(to: CChar.self)
path.withCString { _ = strlcpy(dst, $0, capacity) }
}
return addr
}
Loading