apple-notes-mcp 2.14.2 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -8
- package/build/index.js +1494 -298
- package/docs/FULL-DISK-ACCESS.md +62 -12
- package/docs/NODE-RUNTIME-AND-TCC-PERMISSIONS.md +101 -31
- package/native/broker/apple-notes-mcp-broker.swift +820 -0
- package/package.json +2 -1
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
// apple-notes-mcp-broker
|
|
2
|
+
//
|
|
3
|
+
// An optional, per-user permission broker for apple-notes-mcp. It runs as a
|
|
4
|
+
// LaunchAgent inside a signed app bundle, so macOS holds the bundle (not the
|
|
5
|
+
// MCP host app, and not whichever Node binary the host launched) responsible
|
|
6
|
+
// for Full Disk Access and Notes Automation. Grants survive Node updates and
|
|
7
|
+
// work under hosts that disclaim responsibility for their children.
|
|
8
|
+
//
|
|
9
|
+
// It does not read the Notes database or talk to Notes itself. For each
|
|
10
|
+
// accepted connection it starts the server's own Node entry point as a child,
|
|
11
|
+
// with raw socket stdin and framed stdout/stderr pipes. Children inherit the
|
|
12
|
+
// bundle as their responsible process.
|
|
13
|
+
//
|
|
14
|
+
// Listening socket: a Unix socket created 0600 inside a 0700 directory. Every
|
|
15
|
+
// connection is checked with getpeereid() and refused unless the peer runs as
|
|
16
|
+
// this user. The first line on a connection is a JSON request:
|
|
17
|
+
// {"type": "ping"}
|
|
18
|
+
// -> {"type": "pong", "protocolVersion": 3, "sourceSha256": "...", ...}
|
|
19
|
+
// {"type": "connect", "protocolVersion": 3, "packageVersion": "...",
|
|
20
|
+
// "entrySha256": "...", "env": {"APPLE_NOTES_MCP_TIMEOUT_MS": "30000"}}
|
|
21
|
+
// -> {"type": "ready", "protocolVersion": 3, "pid": <child>, ...}, then the
|
|
22
|
+
// connection carries raw stdin and framed stdout/stderr/exit output.
|
|
23
|
+
// anything else -> {"type": "error", "code": "...", "message": "..."}
|
|
24
|
+
// Only the numeric limits in passedEnvironmentKeys pass through. Paths,
|
|
25
|
+
// helpers, shortcuts, safety overrides, and loader settings are never trusted.
|
|
26
|
+
//
|
|
27
|
+
// Modes:
|
|
28
|
+
// (no arguments) read {"type": "hello"} on stdin, answer with the protocol
|
|
29
|
+
// version and source digest, and exit (build handshake)
|
|
30
|
+
// serve --socket <path>
|
|
31
|
+
// Code paths come exclusively from the signed app bundle. The sealed config
|
|
32
|
+
// pins Node by canonical path and SHA-256; the server entry is bundled.
|
|
33
|
+
//
|
|
34
|
+
// Build (done by `apple-notes-mcp setup --broker`, which also generates the
|
|
35
|
+
// one-line source-digest file that defines helperSourceSHA256):
|
|
36
|
+
// xcrun swiftc -O -parse-as-library apple-notes-mcp-broker.swift source-digest.swift -o apple-notes-mcp-broker
|
|
37
|
+
|
|
38
|
+
import Darwin
|
|
39
|
+
import Foundation
|
|
40
|
+
import CryptoKit
|
|
41
|
+
import Security
|
|
42
|
+
import os.log
|
|
43
|
+
|
|
44
|
+
let protocolVersion = 3
|
|
45
|
+
let maxRequestBytes = 65_536
|
|
46
|
+
let maxChildren = 16
|
|
47
|
+
let maxVariableBytes = 8_192
|
|
48
|
+
let maxFrameBytes = 65_536
|
|
49
|
+
let outputStallSeconds = 30.0
|
|
50
|
+
let finalDrainSeconds = 5.0
|
|
51
|
+
let brokerLog = OSLog(subsystem: "apple-notes-mcp.broker", category: "broker")
|
|
52
|
+
|
|
53
|
+
// Keep in sync with BROKER_PASSED_ENV_KEYS in brokerProxy.ts. This is also
|
|
54
|
+
// enforced here because any same-user process can speak the socket protocol.
|
|
55
|
+
let passedEnvironmentKeys: Set<String> = [
|
|
56
|
+
"APPLE_NOTES_MCP_BLOCKS_MAX_BYTES",
|
|
57
|
+
"APPLE_NOTES_MCP_EXPORT_MAX_BYTES",
|
|
58
|
+
"APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES",
|
|
59
|
+
"APPLE_NOTES_MCP_MAX_BUFFER",
|
|
60
|
+
"APPLE_NOTES_MCP_MAX_INLINE_IMAGE_BYTES",
|
|
61
|
+
"APPLE_NOTES_MCP_MAX_RETRIES",
|
|
62
|
+
"APPLE_NOTES_MCP_PRIVATE_HELPER_TIMEOUT_MS",
|
|
63
|
+
"APPLE_NOTES_MCP_PUBLIC_HELPER_TIMEOUT_MS",
|
|
64
|
+
"APPLE_NOTES_MCP_RETRY_DELAY_MS",
|
|
65
|
+
"APPLE_NOTES_MCP_TIMEOUT_MS",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
func writeAll(_ fd: Int32, _ data: Data) -> Bool {
|
|
69
|
+
return data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> Bool in
|
|
70
|
+
guard var pointer = raw.baseAddress else { return true }
|
|
71
|
+
var remaining = raw.count
|
|
72
|
+
while remaining > 0 {
|
|
73
|
+
let written = Darwin.write(fd, pointer, remaining)
|
|
74
|
+
if written < 0 {
|
|
75
|
+
if errno == EINTR { continue }
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
remaining -= written
|
|
79
|
+
pointer = pointer.advanced(by: written)
|
|
80
|
+
}
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@discardableResult
|
|
86
|
+
func sendLine(_ fd: Int32, _ object: [String: Any]) -> Bool {
|
|
87
|
+
guard var data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
data.append(0x0A)
|
|
91
|
+
return writeAll(fd, data)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// Reads one newline-terminated line a byte at a time, so nothing past the
|
|
95
|
+
/// newline is consumed: the rest of the stream belongs to the child.
|
|
96
|
+
func readRequestLine(_ fd: Int32) -> Data? {
|
|
97
|
+
var line = Data()
|
|
98
|
+
var byte: UInt8 = 0
|
|
99
|
+
while line.count < maxRequestBytes {
|
|
100
|
+
let count = Darwin.read(fd, &byte, 1)
|
|
101
|
+
if count < 0 && errno == EINTR { continue }
|
|
102
|
+
if count <= 0 { return nil }
|
|
103
|
+
if byte == 0x0A { return line }
|
|
104
|
+
line.append(byte)
|
|
105
|
+
}
|
|
106
|
+
return nil
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func log(_ message: String, details: String = "") {
|
|
110
|
+
// OS-managed retention, never an append-only file or child output. Paths
|
|
111
|
+
// in diagnostic details stay private in collected unified logs.
|
|
112
|
+
os_log("%{public}@ %{private}@", log: brokerLog, type: .default, message as NSString, details as NSString)
|
|
113
|
+
// launchd points stderr at /dev/null; a direct CLI caller still gets its
|
|
114
|
+
// own diagnostic stream without the broker opening a destination path.
|
|
115
|
+
FileHandle.standardError.write(Data("[broker] \(message)\(details.isEmpty ? "" : ": " + details)\n".utf8))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// MARK: - Build handshake
|
|
119
|
+
|
|
120
|
+
func runHello(trust: TrustedServer) -> Never {
|
|
121
|
+
let input = FileHandle.standardInput.readDataToEndOfFile()
|
|
122
|
+
let firstLine = input.split(separator: 0x0A).first.map { Data($0) } ?? Data()
|
|
123
|
+
guard
|
|
124
|
+
let object = try? JSONSerialization.jsonObject(with: firstLine) as? [String: Any],
|
|
125
|
+
object["type"] as? String == "hello"
|
|
126
|
+
else {
|
|
127
|
+
FileHandle.standardError.write(Data("expected {\"type\":\"hello\"} on stdin\n".utf8))
|
|
128
|
+
exit(64)
|
|
129
|
+
}
|
|
130
|
+
sendLine(STDOUT_FILENO, [
|
|
131
|
+
"type": "hello",
|
|
132
|
+
"protocolVersion": protocolVersion,
|
|
133
|
+
"sourceSha256": helperSourceSHA256,
|
|
134
|
+
"packageVersion": trust.config.packageVersion,
|
|
135
|
+
"entrySha256": trust.config.entrySha256,
|
|
136
|
+
])
|
|
137
|
+
exit(0)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// MARK: - Serving
|
|
141
|
+
|
|
142
|
+
struct ServeOptions {
|
|
143
|
+
let socketPath: String
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func parseServeOptions(_ args: [String]) -> ServeOptions? {
|
|
147
|
+
var values: [String: String] = [:]
|
|
148
|
+
var index = 0
|
|
149
|
+
while index < args.count {
|
|
150
|
+
let key = args[index]
|
|
151
|
+
guard key == "--socket", values[key] == nil,
|
|
152
|
+
index + 1 < args.count, !args[index + 1].contains("\0") else {
|
|
153
|
+
return nil
|
|
154
|
+
}
|
|
155
|
+
values[key] = args[index + 1]
|
|
156
|
+
index += 2
|
|
157
|
+
}
|
|
158
|
+
guard let socket = values["--socket"], socket.hasPrefix("/") else {
|
|
159
|
+
return nil
|
|
160
|
+
}
|
|
161
|
+
return ServeOptions(socketPath: socket)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// MARK: - Signed code and configuration
|
|
165
|
+
|
|
166
|
+
struct IntegrityError: Error, CustomStringConvertible {
|
|
167
|
+
let description: String
|
|
168
|
+
init(_ description: String) { self.description = description }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
struct ServerConfiguration: Decodable {
|
|
172
|
+
let schemaVersion: Int
|
|
173
|
+
let nodePath: String
|
|
174
|
+
let nodeSha256: String
|
|
175
|
+
let packageVersion: String
|
|
176
|
+
let entrySha256: String
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func sha256File(_ path: String) throws -> String {
|
|
180
|
+
let file = try FileHandle(forReadingFrom: URL(fileURLWithPath: path))
|
|
181
|
+
defer { try? file.close() }
|
|
182
|
+
var digest = SHA256()
|
|
183
|
+
while let data = try file.read(upToCount: 1_048_576), !data.isEmpty {
|
|
184
|
+
digest.update(data: data)
|
|
185
|
+
}
|
|
186
|
+
return digest.finalize().map { String(format: "%02x", $0) }.joined()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func isSHA256(_ value: String) -> Bool {
|
|
190
|
+
value.utf8.count == 64 && value.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
func canonicalPath(_ path: String) throws -> String {
|
|
194
|
+
guard !path.contains("\0"), let resolved = realpath(path, nil) else {
|
|
195
|
+
throw IntegrityError("Cannot resolve path: \(path)")
|
|
196
|
+
}
|
|
197
|
+
defer { free(resolved) }
|
|
198
|
+
return String(cString: resolved)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// Returns only existing regular files whose path has no symlink components.
|
|
202
|
+
func requireCanonicalFile(_ path: String) throws {
|
|
203
|
+
var info = stat()
|
|
204
|
+
guard path.hasPrefix("/"), !path.contains("\0"),
|
|
205
|
+
try canonicalPath(path) == path,
|
|
206
|
+
lstat(path, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else {
|
|
207
|
+
throw IntegrityError("Code path is missing, noncanonical, or not a regular file: \(path)")
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// The running image supplies the requirement, never a mutable external
|
|
212
|
+
/// manifest. Strict validation seals resources as well as the executable.
|
|
213
|
+
/// Revalidate at each spawn to detect replacement since startup. This does not
|
|
214
|
+
/// make file checks atomic with Node's later reads or isolate an unsandboxed
|
|
215
|
+
/// same-user process actively racing writes to the installation.
|
|
216
|
+
final class TrustedServer {
|
|
217
|
+
let bundlePath: String
|
|
218
|
+
let resourcesPath: String
|
|
219
|
+
let entryPath: String
|
|
220
|
+
let config: ServerConfiguration
|
|
221
|
+
private let requirement: SecRequirement
|
|
222
|
+
|
|
223
|
+
init() throws {
|
|
224
|
+
bundlePath = try canonicalPath(Bundle.main.bundleURL.path)
|
|
225
|
+
resourcesPath = bundlePath + "/Contents/Resources"
|
|
226
|
+
entryPath = resourcesPath + "/server/build/index.js"
|
|
227
|
+
guard bundlePath.hasSuffix(".app"), let executable = Bundle.main.executableURL,
|
|
228
|
+
try canonicalPath(executable.path) ==
|
|
229
|
+
bundlePath + "/Contents/MacOS/apple-notes-mcp-broker" else {
|
|
230
|
+
throw IntegrityError("The broker must run from its signed app bundle.")
|
|
231
|
+
}
|
|
232
|
+
var running: SecCode?
|
|
233
|
+
var runningStatic: SecStaticCode?
|
|
234
|
+
var ownRequirement: SecRequirement?
|
|
235
|
+
guard SecCodeCopySelf(SecCSFlags(), &running) == errSecSuccess,
|
|
236
|
+
let running,
|
|
237
|
+
SecCodeCheckValidity(running, SecCSFlags(), nil) == errSecSuccess,
|
|
238
|
+
SecCodeCopyStaticCode(running, SecCSFlags(), &runningStatic) == errSecSuccess,
|
|
239
|
+
let runningStatic,
|
|
240
|
+
SecCodeCopyDesignatedRequirement(runningStatic, SecCSFlags(), &ownRequirement) == errSecSuccess,
|
|
241
|
+
let ownRequirement else {
|
|
242
|
+
throw IntegrityError("Cannot verify the running broker's code signature.")
|
|
243
|
+
}
|
|
244
|
+
requirement = ownRequirement
|
|
245
|
+
try Self.verifyBundle(bundlePath, requirement: ownRequirement)
|
|
246
|
+
let configPath = resourcesPath + "/broker-config.json"
|
|
247
|
+
try requireCanonicalFile(configPath)
|
|
248
|
+
let data = try Data(contentsOf: URL(fileURLWithPath: configPath))
|
|
249
|
+
guard data.count <= maxRequestBytes else { throw IntegrityError("The sealed broker config is too large.") }
|
|
250
|
+
config = try JSONDecoder().decode(ServerConfiguration.self, from: data)
|
|
251
|
+
guard config.schemaVersion == 1, isSHA256(config.nodeSha256), isSHA256(config.entrySha256),
|
|
252
|
+
!config.packageVersion.isEmpty, config.packageVersion.utf8.count <= 256 else {
|
|
253
|
+
throw IntegrityError("The sealed broker config is invalid. Run setup --broker again.")
|
|
254
|
+
}
|
|
255
|
+
try validate()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private static func verifyBundle(_ path: String, requirement: SecRequirement) throws {
|
|
259
|
+
var code: SecStaticCode?
|
|
260
|
+
let flags = SecCSFlags(rawValue: kSecCSStrictValidate | kSecCSCheckAllArchitectures)
|
|
261
|
+
guard SecStaticCodeCreateWithPath(URL(fileURLWithPath: path) as CFURL, SecCSFlags(), &code) == errSecSuccess,
|
|
262
|
+
let code,
|
|
263
|
+
SecStaticCodeCheckValidity(code, flags, requirement) == errSecSuccess else {
|
|
264
|
+
throw IntegrityError("The broker app's signature or sealed resources changed. Run setup --broker again.")
|
|
265
|
+
}
|
|
266
|
+
var signing: CFDictionary?
|
|
267
|
+
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &signing) == errSecSuccess,
|
|
268
|
+
let info = signing as? [String: Any],
|
|
269
|
+
let signedFlags = info[kSecCodeInfoFlags as String] as? NSNumber,
|
|
270
|
+
signedFlags.uint32Value & 0x10000 != 0 else { // CS_RUNTIME
|
|
271
|
+
throw IntegrityError("The broker must be signed with the hardened runtime enabled.")
|
|
272
|
+
}
|
|
273
|
+
// These entitlements would defeat the broker's loader boundary.
|
|
274
|
+
let entitlements = info[kSecCodeInfoEntitlementsDict as String] as? [String: Any] ?? [:]
|
|
275
|
+
for key in ["com.apple.security.cs.allow-dyld-environment-variables",
|
|
276
|
+
"com.apple.security.cs.disable-library-validation",
|
|
277
|
+
"com.apple.security.get-task-allow"] {
|
|
278
|
+
if (entitlements[key] as? NSNumber)?.boolValue == true {
|
|
279
|
+
throw IntegrityError("The broker has an unsafe code-signing entitlement: \(key)")
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
func validate() throws {
|
|
285
|
+
try Self.verifyBundle(bundlePath, requirement: requirement)
|
|
286
|
+
try requireCanonicalFile(entryPath)
|
|
287
|
+
try requireCanonicalFile(config.nodePath)
|
|
288
|
+
guard try sha256File(entryPath) == config.entrySha256,
|
|
289
|
+
try sha256File(config.nodePath) == config.nodeSha256 else {
|
|
290
|
+
throw IntegrityError("The server entry or pinned Node runtime changed. Run setup --broker again.")
|
|
291
|
+
}
|
|
292
|
+
try requireCanonicalFile(resourcesPath + "/config.json")
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
final class Broker {
|
|
297
|
+
let options: ServeOptions
|
|
298
|
+
let trust: TrustedServer
|
|
299
|
+
let bundlePath: String
|
|
300
|
+
private let lock = NSLock()
|
|
301
|
+
private var active = 0
|
|
302
|
+
private var stopping = false
|
|
303
|
+
private var connections: Set<Int32> = []
|
|
304
|
+
|
|
305
|
+
init(options: ServeOptions, trust: TrustedServer) {
|
|
306
|
+
self.options = options
|
|
307
|
+
self.trust = trust
|
|
308
|
+
bundlePath = trust.bundlePath
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
var activeChildren: Int {
|
|
312
|
+
lock.lock()
|
|
313
|
+
defer { lock.unlock() }
|
|
314
|
+
return active
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private func reserveChild() -> Bool {
|
|
318
|
+
lock.lock()
|
|
319
|
+
defer { lock.unlock() }
|
|
320
|
+
if stopping || active >= maxChildren { return false }
|
|
321
|
+
active += 1
|
|
322
|
+
return true
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private func releaseChild() {
|
|
326
|
+
lock.lock()
|
|
327
|
+
active -= 1
|
|
328
|
+
lock.unlock()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private var isStopping: Bool {
|
|
332
|
+
lock.lock()
|
|
333
|
+
defer { lock.unlock() }
|
|
334
|
+
return stopping
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private func stop() {
|
|
338
|
+
lock.lock()
|
|
339
|
+
stopping = true
|
|
340
|
+
// Wake handshakes and child stdin without closing/reusing descriptors
|
|
341
|
+
// while their owning connection threads are still running.
|
|
342
|
+
for connection in connections { shutdown(connection, SHUT_RDWR) }
|
|
343
|
+
lock.unlock()
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private func closeConnection(_ connection: Int32) {
|
|
347
|
+
lock.lock()
|
|
348
|
+
connections.remove(connection)
|
|
349
|
+
close(connection)
|
|
350
|
+
lock.unlock()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func listen() -> Int32 {
|
|
354
|
+
let path = options.socketPath
|
|
355
|
+
let directory = (path as NSString).deletingLastPathComponent
|
|
356
|
+
do {
|
|
357
|
+
try FileManager.default.createDirectory(
|
|
358
|
+
atPath: directory, withIntermediateDirectories: true,
|
|
359
|
+
attributes: [.posixPermissions: 0o700])
|
|
360
|
+
} catch {
|
|
361
|
+
log("cannot create socket directory", details: "\(directory): \(error)")
|
|
362
|
+
exit(73)
|
|
363
|
+
}
|
|
364
|
+
// The directory is the first fence: owned by this user and closed to everyone else.
|
|
365
|
+
var info = stat()
|
|
366
|
+
guard lstat(directory, &info) == 0, (info.st_mode & S_IFMT) == S_IFDIR, info.st_uid == getuid() else {
|
|
367
|
+
log("socket directory is not owned by this user", details: directory)
|
|
368
|
+
exit(73)
|
|
369
|
+
}
|
|
370
|
+
chmod(directory, 0o700)
|
|
371
|
+
if lstat(path, &info) == 0 {
|
|
372
|
+
guard (info.st_mode & S_IFMT) == S_IFSOCK else {
|
|
373
|
+
log("refusing to replace a non-socket path", details: path)
|
|
374
|
+
exit(73)
|
|
375
|
+
}
|
|
376
|
+
unlink(path)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
|
380
|
+
guard fd >= 0 else {
|
|
381
|
+
log("socket() failed: \(String(cString: strerror(errno)))")
|
|
382
|
+
exit(71)
|
|
383
|
+
}
|
|
384
|
+
var address = sockaddr_un()
|
|
385
|
+
address.sun_family = sa_family_t(AF_UNIX)
|
|
386
|
+
let capacity = MemoryLayout.size(ofValue: address.sun_path)
|
|
387
|
+
let pathBytes = Array(path.utf8)
|
|
388
|
+
guard pathBytes.count < capacity else {
|
|
389
|
+
log("socket path is too long", details: path)
|
|
390
|
+
exit(64)
|
|
391
|
+
}
|
|
392
|
+
withUnsafeMutableBytes(of: &address.sun_path) { buffer in
|
|
393
|
+
buffer.copyBytes(from: pathBytes)
|
|
394
|
+
buffer[pathBytes.count] = 0
|
|
395
|
+
}
|
|
396
|
+
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
|
|
397
|
+
// Created 0600 from the start, so there is no window where it is open to others.
|
|
398
|
+
let previousMask = umask(0o177)
|
|
399
|
+
let bound = withUnsafePointer(to: &address) {
|
|
400
|
+
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
|
401
|
+
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
umask(previousMask)
|
|
405
|
+
guard bound == 0 else {
|
|
406
|
+
log("socket bind failed", details: "\(path): \(String(cString: strerror(errno)))")
|
|
407
|
+
exit(71)
|
|
408
|
+
}
|
|
409
|
+
chmod(path, 0o600)
|
|
410
|
+
guard Darwin.listen(fd, 16) == 0 else {
|
|
411
|
+
log("listen() failed: \(String(cString: strerror(errno)))")
|
|
412
|
+
exit(71)
|
|
413
|
+
}
|
|
414
|
+
// A queued peer can disappear between poll and accept. Nonblocking
|
|
415
|
+
// accept keeps daemon cancellation responsive in that race.
|
|
416
|
+
let flags = fcntl(fd, F_GETFL)
|
|
417
|
+
guard flags >= 0, fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0 else {
|
|
418
|
+
log("could not configure the listening socket")
|
|
419
|
+
exit(71)
|
|
420
|
+
}
|
|
421
|
+
return fd
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
func serve() -> Never {
|
|
425
|
+
signal(SIGPIPE, SIG_IGN)
|
|
426
|
+
// Never inherit automatic child reaping: WNOWAIT must reserve the
|
|
427
|
+
// direct child's PID until its process group has been cleaned up.
|
|
428
|
+
signal(SIGCHLD, SIG_DFL)
|
|
429
|
+
signal(SIGTERM, SIG_IGN)
|
|
430
|
+
signal(SIGINT, SIG_IGN)
|
|
431
|
+
let signals = [SIGTERM, SIGINT].map { number -> DispatchSourceSignal in
|
|
432
|
+
let source = DispatchSource.makeSignalSource(signal: number, queue: .global())
|
|
433
|
+
source.setEventHandler { [self] in stop() }
|
|
434
|
+
source.resume()
|
|
435
|
+
return source
|
|
436
|
+
}
|
|
437
|
+
let listener = listen()
|
|
438
|
+
log("broker is listening", details: options.socketPath)
|
|
439
|
+
while !isStopping {
|
|
440
|
+
var ready = pollfd(fd: listener, events: Int16(POLLIN), revents: 0)
|
|
441
|
+
guard poll(&ready, 1, 100) > 0 else { continue }
|
|
442
|
+
let connection = accept(listener, nil, nil)
|
|
443
|
+
if connection < 0 {
|
|
444
|
+
if errno == EINTR || errno == ECONNABORTED || errno == EAGAIN || errno == EWOULDBLOCK { continue }
|
|
445
|
+
log("accept() failed: \(String(cString: strerror(errno)))")
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
// BSD can inherit listener flags. Child stdin must stay blocking;
|
|
449
|
+
// relay writes use MSG_DONTWAIT without changing this descriptor.
|
|
450
|
+
let connectionFlags = fcntl(connection, F_GETFL)
|
|
451
|
+
guard connectionFlags >= 0,
|
|
452
|
+
fcntl(connection, F_SETFL, connectionFlags & ~O_NONBLOCK) == 0 else {
|
|
453
|
+
close(connection)
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
lock.lock()
|
|
457
|
+
if stopping {
|
|
458
|
+
close(connection)
|
|
459
|
+
lock.unlock()
|
|
460
|
+
break
|
|
461
|
+
}
|
|
462
|
+
connections.insert(connection)
|
|
463
|
+
lock.unlock()
|
|
464
|
+
Thread.detachNewThread { [self] in
|
|
465
|
+
handle(connection)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
close(listener)
|
|
469
|
+
stop()
|
|
470
|
+
// Each session observes cancellation within a poll interval, signals
|
|
471
|
+
// its own process group, and reaps its child before leaving this set.
|
|
472
|
+
while true {
|
|
473
|
+
lock.lock()
|
|
474
|
+
let empty = connections.isEmpty
|
|
475
|
+
lock.unlock()
|
|
476
|
+
if empty { break }
|
|
477
|
+
usleep(10_000)
|
|
478
|
+
}
|
|
479
|
+
for source in signals { source.cancel() }
|
|
480
|
+
unlink(options.socketPath)
|
|
481
|
+
exit(0)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private func refuse(_ fd: Int32, _ code: String, _ message: String) {
|
|
485
|
+
sendLine(fd, ["type": "error", "code": code, "message": message])
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private func handle(_ connection: Int32) {
|
|
489
|
+
defer { closeConnection(connection) }
|
|
490
|
+
var peerUID: uid_t = 0
|
|
491
|
+
var peerGID: gid_t = 0
|
|
492
|
+
guard getpeereid(connection, &peerUID, &peerGID) == 0, peerUID == getuid() else {
|
|
493
|
+
log("refused a connection from uid \(peerUID)")
|
|
494
|
+
refuse(connection, "peer_refused", "Only the user who installed the broker may connect.")
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
var timeout = timeval(tv_sec: 10, tv_usec: 0)
|
|
498
|
+
setsockopt(connection, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
|
|
499
|
+
setsockopt(connection, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size))
|
|
500
|
+
|
|
501
|
+
guard
|
|
502
|
+
let line = readRequestLine(connection),
|
|
503
|
+
let request = try? JSONSerialization.jsonObject(with: line) as? [String: Any],
|
|
504
|
+
let type = request["type"] as? String
|
|
505
|
+
else {
|
|
506
|
+
refuse(connection, "bad_request", "Expected one JSON request line.")
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
switch type {
|
|
510
|
+
case "ping":
|
|
511
|
+
sendLine(connection, [
|
|
512
|
+
"type": "pong",
|
|
513
|
+
"protocolVersion": protocolVersion,
|
|
514
|
+
"sourceSha256": helperSourceSHA256,
|
|
515
|
+
"pid": Int(getpid()),
|
|
516
|
+
"bundlePath": bundlePath,
|
|
517
|
+
"nodePath": trust.config.nodePath,
|
|
518
|
+
"entryPath": trust.entryPath,
|
|
519
|
+
"packageVersion": trust.config.packageVersion,
|
|
520
|
+
"entrySha256": trust.config.entrySha256,
|
|
521
|
+
"activeChildren": activeChildren,
|
|
522
|
+
])
|
|
523
|
+
case "connect":
|
|
524
|
+
guard request["protocolVersion"] as? Int == protocolVersion else {
|
|
525
|
+
refuse(connection, "protocol_mismatch",
|
|
526
|
+
"The broker speaks protocol \(protocolVersion). Run `apple-notes-mcp setup --broker` again.")
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
guard request["packageVersion"] as? String == trust.config.packageVersion,
|
|
530
|
+
request["entrySha256"] as? String == trust.config.entrySha256 else {
|
|
531
|
+
refuse(connection, "version_mismatch",
|
|
532
|
+
"This client does not match the broker's sealed server. Run `apple-notes-mcp setup --broker` again.")
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
connect(connection, passed: request["env"] as? [String: Any] ?? [:])
|
|
536
|
+
default:
|
|
537
|
+
refuse(connection, "bad_request", "Unknown request type.")
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/// Never inherit the LaunchAgent's environment. HOME and TMPDIR also
|
|
542
|
+
/// influence file/config discovery, so derive them from the OS account.
|
|
543
|
+
private func childEnvironment(passed: [String: Any]) -> [String] {
|
|
544
|
+
var env: [String: String] = [
|
|
545
|
+
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
|
|
546
|
+
"LANG": "en_US.UTF-8",
|
|
547
|
+
"SHELL": "/bin/sh",
|
|
548
|
+
]
|
|
549
|
+
// getpwuid_r is safe across concurrent connection threads.
|
|
550
|
+
var account = passwd()
|
|
551
|
+
var accountPointer: UnsafeMutablePointer<passwd>?
|
|
552
|
+
var buffer = [CChar](repeating: 0, count: 16_384)
|
|
553
|
+
if getpwuid_r(getuid(), &account, &buffer, buffer.count, &accountPointer) == 0,
|
|
554
|
+
accountPointer != nil {
|
|
555
|
+
env["HOME"] = String(cString: account.pw_dir)
|
|
556
|
+
env["USER"] = String(cString: account.pw_name)
|
|
557
|
+
env["LOGNAME"] = env["USER"]
|
|
558
|
+
} else {
|
|
559
|
+
// Fail closed rather than letting Node consult inherited values.
|
|
560
|
+
env["HOME"] = "/var/empty"
|
|
561
|
+
}
|
|
562
|
+
var temporary = [CChar](repeating: 0, count: Int(PATH_MAX))
|
|
563
|
+
let length = confstr(_CS_DARWIN_USER_TEMP_DIR, &temporary, temporary.count)
|
|
564
|
+
env["TMPDIR"] = length > 0 && length <= temporary.count ? String(cString: temporary) : "/tmp"
|
|
565
|
+
for key in passedEnvironmentKeys {
|
|
566
|
+
guard let raw = passed[key],
|
|
567
|
+
let value = raw as? String, value.utf8.count <= maxVariableBytes,
|
|
568
|
+
!value.isEmpty, value.utf8.allSatisfy({ (48...57).contains($0) })
|
|
569
|
+
else { continue }
|
|
570
|
+
// Match the proxy's positive Int32 range, including leading zeros.
|
|
571
|
+
let digits = value.drop(while: { $0 == "0" })
|
|
572
|
+
guard let number = Int64(digits), number > 0, number <= Int64(Int32.max) else { continue }
|
|
573
|
+
env[key] = value
|
|
574
|
+
}
|
|
575
|
+
env["APPLE_NOTES_MCP_BROKERED"] = "1"
|
|
576
|
+
env["APPLE_NOTES_MCP_BROKER_APP"] = bundlePath
|
|
577
|
+
env["APPLE_NOTES_MCP_CONFIG_FILE"] = trust.resourcesPath + "/config.json"
|
|
578
|
+
// Mutable helper manifests only hash their mutable binaries; they are
|
|
579
|
+
// not a trust anchor. Helpers require a separately sealed integration.
|
|
580
|
+
// /dev/null is a root-owned non-directory, so even an already-running
|
|
581
|
+
// child cannot discover a helper added after bundle verification.
|
|
582
|
+
env["APPLE_NOTES_MCP_PUBLIC_HELPER_DIR"] = "/dev/null"
|
|
583
|
+
env["APPLE_NOTES_MCP_PRIVATE_HELPER_DIR"] = "/dev/null"
|
|
584
|
+
env["APPLE_NOTES_MCP_ENABLE_PRIVATE"] = "0"
|
|
585
|
+
env["APPLE_NOTES_MCP_ALLOW_PRIVATE_CONTENT_PATHS"] = "0"
|
|
586
|
+
env["APPLE_NOTES_MCP_ALLOW_UNVERIFIED"] = "0"
|
|
587
|
+
return env.map { "\($0.key)=\($0.value)" }
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/// Keep the child unreaped until all signals to its process group are
|
|
591
|
+
/// finished. Its reserved PID prevents signalling an unrelated reused ID.
|
|
592
|
+
private func terminateAndReap(_ pid: pid_t) {
|
|
593
|
+
kill(-pid, SIGTERM)
|
|
594
|
+
usleep(250_000)
|
|
595
|
+
kill(-pid, SIGKILL)
|
|
596
|
+
var status: Int32 = 0
|
|
597
|
+
while waitpid(pid, &status, 0) < 0 && errno == EINTR {}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private func frame(_ channel: UInt8, _ payload: Data) -> Data {
|
|
601
|
+
let length = UInt32(payload.count)
|
|
602
|
+
var data = Data([channel, UInt8((length >> 24) & 255), UInt8((length >> 16) & 255),
|
|
603
|
+
UInt8((length >> 8) & 255), UInt8(length & 255)])
|
|
604
|
+
data.append(payload)
|
|
605
|
+
return data
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/// Serialize stdout and stderr with one bounded frame in flight. Pipe
|
|
609
|
+
/// reads stop while the socket is backpressured; no user-space queue grows.
|
|
610
|
+
/// Child stdin retains the blocking socket descriptor: O_NONBLOCK here
|
|
611
|
+
/// would also change the child's dup, so only send uses MSG_DONTWAIT.
|
|
612
|
+
private func relay(_ connection: Int32, pid: pid_t, output: Int32, errors: Int32) {
|
|
613
|
+
let pipes = [output, errors]
|
|
614
|
+
var open = [true, true]
|
|
615
|
+
var nextPipe = 0
|
|
616
|
+
var pending = Data()
|
|
617
|
+
var sent = 0
|
|
618
|
+
var terminal = false
|
|
619
|
+
var exitCode: UInt32?
|
|
620
|
+
var exitedAt: TimeInterval?
|
|
621
|
+
var progressAt = ProcessInfo.processInfo.systemUptime
|
|
622
|
+
var bytes = [UInt8](repeating: 0, count: maxFrameBytes)
|
|
623
|
+
for fd in pipes { _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) }
|
|
624
|
+
|
|
625
|
+
while !isStopping {
|
|
626
|
+
let now = ProcessInfo.processInfo.systemUptime
|
|
627
|
+
// Darwin reports full peer closure to POLLOUT, but not to an
|
|
628
|
+
// events=0 poll. Probe separately so idle writable sockets don't
|
|
629
|
+
// spin the blocking poll below. POLLIN also reports HUP for a
|
|
630
|
+
// legitimate stdin half-close, so it must not be used here.
|
|
631
|
+
var peer = pollfd(fd: connection, events: Int16(POLLOUT), revents: 0)
|
|
632
|
+
if poll(&peer, 1, 0) > 0 && peer.revents & Int16(POLLHUP | POLLERR | POLLNVAL) != 0 {
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
if exitCode == nil {
|
|
636
|
+
var info = siginfo_t()
|
|
637
|
+
if waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG | WNOWAIT) < 0 {
|
|
638
|
+
if errno == EINTR { continue }
|
|
639
|
+
log("could not observe a broker child exit")
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
if info.si_pid == pid {
|
|
643
|
+
exitCode = info.si_code == CLD_EXITED ? UInt32(info.si_status & 255) :
|
|
644
|
+
UInt32(min(255, 128 + info.si_status))
|
|
645
|
+
exitedAt = now
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if let exitedAt, now - exitedAt > finalDrainSeconds {
|
|
649
|
+
log("closed a session whose final output did not drain")
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
if !pending.isEmpty && now - progressAt > outputStallSeconds {
|
|
653
|
+
log("closed a session whose client stopped reading output")
|
|
654
|
+
return
|
|
655
|
+
}
|
|
656
|
+
if pending.isEmpty, !open[0], !open[1], let code = exitCode {
|
|
657
|
+
pending = frame(3, Data([0, 0, 0, UInt8(code)]))
|
|
658
|
+
terminal = true
|
|
659
|
+
progressAt = now
|
|
660
|
+
}
|
|
661
|
+
var descriptors = [pollfd(fd: connection, events: pending.isEmpty ? 0 : Int16(POLLOUT), revents: 0)]
|
|
662
|
+
for index in 0..<2 {
|
|
663
|
+
descriptors.append(pollfd(fd: open[index] && pending.isEmpty ? pipes[index] : -1,
|
|
664
|
+
events: Int16(POLLIN), revents: 0))
|
|
665
|
+
}
|
|
666
|
+
let result = poll(&descriptors, nfds_t(descriptors.count), 100)
|
|
667
|
+
if result < 0 {
|
|
668
|
+
if errno == EINTR { continue }
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
// For this POLLOUT subscription, SHUT_WR is not a disconnect.
|
|
672
|
+
if descriptors[0].revents & Int16(POLLHUP | POLLERR | POLLNVAL) != 0 { return }
|
|
673
|
+
if !pending.isEmpty && descriptors[0].revents & Int16(POLLOUT) != 0 {
|
|
674
|
+
let count = pending.withUnsafeBytes { raw in
|
|
675
|
+
Darwin.send(connection, raw.baseAddress!.advanced(by: sent), pending.count - sent, MSG_DONTWAIT)
|
|
676
|
+
}
|
|
677
|
+
if count > 0 {
|
|
678
|
+
sent += count
|
|
679
|
+
progressAt = now
|
|
680
|
+
if sent == pending.count {
|
|
681
|
+
pending.removeAll(keepingCapacity: true)
|
|
682
|
+
sent = 0
|
|
683
|
+
if terminal { return }
|
|
684
|
+
}
|
|
685
|
+
} else if count == 0 || (errno != EINTR && errno != EAGAIN && errno != EWOULDBLOCK) {
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if !pending.isEmpty { continue }
|
|
690
|
+
// Alternate when both pipes are readable, so a stdout flood does
|
|
691
|
+
// not starve stderr (or vice versa).
|
|
692
|
+
for offset in 0..<2 {
|
|
693
|
+
let index = (nextPipe + offset) % 2
|
|
694
|
+
if !open[index] || descriptors[index + 1].revents == 0 { continue }
|
|
695
|
+
let count = Darwin.read(pipes[index], &bytes, bytes.count)
|
|
696
|
+
if count > 0 {
|
|
697
|
+
pending = frame(UInt8(index + 1), Data(bytes.prefix(count)))
|
|
698
|
+
nextPipe = (index + 1) % 2
|
|
699
|
+
progressAt = now
|
|
700
|
+
break
|
|
701
|
+
}
|
|
702
|
+
if count == 0 { open[index] = false }
|
|
703
|
+
else if errno != EINTR && errno != EAGAIN && errno != EWOULDBLOCK { return }
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private func connect(_ connection: Int32, passed: [String: Any]) {
|
|
709
|
+
guard reserveChild() else {
|
|
710
|
+
log("refused a connection: child limit \(maxChildren) reached or broker stopping")
|
|
711
|
+
refuse(connection, "busy", "The broker cannot accept another client now.")
|
|
712
|
+
return
|
|
713
|
+
}
|
|
714
|
+
defer { releaseChild() }
|
|
715
|
+
do {
|
|
716
|
+
try trust.validate()
|
|
717
|
+
} catch {
|
|
718
|
+
log("refused spawn after integrity failure", details: String(describing: error))
|
|
719
|
+
refuse(connection, "integrity_failed", "The broker's sealed server or pinned runtime changed. Run setup --broker again.")
|
|
720
|
+
return
|
|
721
|
+
}
|
|
722
|
+
var noTimeout = timeval(tv_sec: 0, tv_usec: 0)
|
|
723
|
+
setsockopt(connection, SOL_SOCKET, SO_RCVTIMEO, &noTimeout, socklen_t(MemoryLayout<timeval>.size))
|
|
724
|
+
var output: [Int32] = [-1, -1]
|
|
725
|
+
var errors: [Int32] = [-1, -1]
|
|
726
|
+
guard pipe(&output) == 0 else {
|
|
727
|
+
refuse(connection, "spawn_failed", "The broker could not create its output pipe.")
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
defer { for fd in output where fd >= 0 { close(fd) } }
|
|
731
|
+
guard pipe(&errors) == 0 else {
|
|
732
|
+
refuse(connection, "spawn_failed", "The broker could not create its error pipe.")
|
|
733
|
+
return
|
|
734
|
+
}
|
|
735
|
+
defer { for fd in errors where fd >= 0 { close(fd) } }
|
|
736
|
+
for fd in output + errors { _ = fcntl(fd, F_SETFD, FD_CLOEXEC) }
|
|
737
|
+
var actions: posix_spawn_file_actions_t?
|
|
738
|
+
guard posix_spawn_file_actions_init(&actions) == 0 else {
|
|
739
|
+
refuse(connection, "spawn_failed", "The broker could not initialize its child descriptors.")
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
defer { posix_spawn_file_actions_destroy(&actions) }
|
|
743
|
+
guard posix_spawn_file_actions_adddup2(&actions, connection, STDIN_FILENO) == 0,
|
|
744
|
+
posix_spawn_file_actions_adddup2(&actions, output[1], STDOUT_FILENO) == 0,
|
|
745
|
+
posix_spawn_file_actions_adddup2(&actions, errors[1], STDERR_FILENO) == 0,
|
|
746
|
+
posix_spawn_file_actions_addchdir_np(&actions, trust.resourcesPath + "/server") == 0 else {
|
|
747
|
+
refuse(connection, "spawn_failed", "The broker could not configure its child descriptors.")
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
var attributes: posix_spawnattr_t?
|
|
751
|
+
guard posix_spawnattr_init(&attributes) == 0 else {
|
|
752
|
+
refuse(connection, "spawn_failed", "The broker could not initialize its child attributes.")
|
|
753
|
+
return
|
|
754
|
+
}
|
|
755
|
+
defer { posix_spawnattr_destroy(&attributes) }
|
|
756
|
+
// A session owns exactly this child's process group, and resets signal
|
|
757
|
+
// dispositions ignored by the broker so cancellation reaches children.
|
|
758
|
+
var defaults = sigset_t()
|
|
759
|
+
sigemptyset(&defaults)
|
|
760
|
+
for number in [SIGTERM, SIGINT, SIGPIPE] { sigaddset(&defaults, number) }
|
|
761
|
+
var mask = sigset_t()
|
|
762
|
+
sigemptyset(&mask)
|
|
763
|
+
let flags = Int16(POSIX_SPAWN_CLOEXEC_DEFAULT | POSIX_SPAWN_SETPGROUP |
|
|
764
|
+
POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK)
|
|
765
|
+
guard posix_spawnattr_setpgroup(&attributes, 0) == 0,
|
|
766
|
+
posix_spawnattr_setsigdefault(&attributes, &defaults) == 0,
|
|
767
|
+
posix_spawnattr_setsigmask(&attributes, &mask) == 0,
|
|
768
|
+
posix_spawnattr_setflags(&attributes, flags) == 0 else {
|
|
769
|
+
refuse(connection, "spawn_failed", "The broker could not configure its child process group and signals.")
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
let argv: [UnsafeMutablePointer<CChar>?] = [strdup(trust.config.nodePath), strdup(trust.entryPath), nil]
|
|
773
|
+
let envp: [UnsafeMutablePointer<CChar>?] = childEnvironment(passed: passed).map { strdup($0) } + [nil]
|
|
774
|
+
defer {
|
|
775
|
+
argv.forEach { free($0) }
|
|
776
|
+
envp.forEach { free($0) }
|
|
777
|
+
}
|
|
778
|
+
if isStopping { return }
|
|
779
|
+
var pid: pid_t = 0
|
|
780
|
+
let status = posix_spawn(&pid, trust.config.nodePath, &actions, &attributes, argv, envp)
|
|
781
|
+
guard status == 0 else {
|
|
782
|
+
log("could not start runtime: \(String(cString: strerror(status)))")
|
|
783
|
+
refuse(connection, "spawn_failed", "The broker could not start its pinned runtime.")
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
close(output[1]); output[1] = -1
|
|
787
|
+
close(errors[1]); errors[1] = -1
|
|
788
|
+
defer { terminateAndReap(pid) }
|
|
789
|
+
// Both child output pipes remain unread until the complete JSON ready
|
|
790
|
+
// line is sent, even if the child writes immediately on startup.
|
|
791
|
+
guard sendLine(connection, ["type": "ready", "protocolVersion": protocolVersion, "pid": Int(pid),
|
|
792
|
+
"packageVersion": trust.config.packageVersion,
|
|
793
|
+
"entrySha256": trust.config.entrySha256]) else { return }
|
|
794
|
+
relay(connection, pid: pid, output: output[0], errors: errors[0])
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// MARK: - Entry
|
|
800
|
+
|
|
801
|
+
@main
|
|
802
|
+
enum BrokerMain {
|
|
803
|
+
static func main() {
|
|
804
|
+
let arguments = Array(CommandLine.arguments.dropFirst())
|
|
805
|
+
do {
|
|
806
|
+
if arguments.isEmpty {
|
|
807
|
+
runHello(trust: try TrustedServer())
|
|
808
|
+
}
|
|
809
|
+
if arguments.first == "serve", let options = parseServeOptions(Array(arguments.dropFirst())) {
|
|
810
|
+
Broker(options: options, trust: try TrustedServer()).serve()
|
|
811
|
+
}
|
|
812
|
+
} catch {
|
|
813
|
+
log("integrity check failed", details: String(describing: error))
|
|
814
|
+
exit(78)
|
|
815
|
+
}
|
|
816
|
+
FileHandle.standardError.write(Data(
|
|
817
|
+
"usage: apple-notes-mcp-broker serve --socket <path>\n".utf8))
|
|
818
|
+
exit(64)
|
|
819
|
+
}
|
|
820
|
+
}
|