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.
@@ -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
+ }