things-api 0.16.0 → 0.18.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.
Files changed (208) hide show
  1. package/README.md +1 -1
  2. package/deputy/VERSION +1 -0
  3. package/deputy/helpers-Info.plist +23 -0
  4. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +20 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +115 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +23 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  10. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +125 -0
  11. package/deputy/reader/Info.plist +20 -0
  12. package/deputy/reader/entitlements.plist +15 -0
  13. package/deputy/reader/main.swift +491 -0
  14. package/deputy/src/main.swift +133 -0
  15. package/deputy/src/osascript.swift +102 -0
  16. package/deputy/src/server.swift +350 -0
  17. package/deputy/src/sqlite.swift +121 -0
  18. package/dist/cli/commands/doctor.js +55 -2
  19. package/dist/cli/commands/doctor.js.map +1 -1
  20. package/dist/cli/commands/helpers.d.ts +17 -0
  21. package/dist/cli/commands/helpers.js +148 -0
  22. package/dist/cli/commands/helpers.js.map +1 -0
  23. package/dist/cli/commands/op-result.d.ts +15 -0
  24. package/dist/cli/commands/op-result.js +39 -0
  25. package/dist/cli/commands/op-result.js.map +1 -0
  26. package/dist/cli/commands/repeat-flags.d.ts +3 -3
  27. package/dist/cli/commands/repeat-flags.js +90 -33
  28. package/dist/cli/commands/repeat-flags.js.map +1 -1
  29. package/dist/cli/commands/writes.js +79 -6
  30. package/dist/cli/commands/writes.js.map +1 -1
  31. package/dist/cli/help.js +13 -2
  32. package/dist/cli/help.js.map +1 -1
  33. package/dist/cli/helpers-check.d.ts +10 -0
  34. package/dist/cli/helpers-check.js +59 -0
  35. package/dist/cli/helpers-check.js.map +1 -0
  36. package/dist/cli/interrupt.d.ts +42 -0
  37. package/dist/cli/interrupt.js +105 -0
  38. package/dist/cli/interrupt.js.map +1 -0
  39. package/dist/cli/main.js +27 -1
  40. package/dist/cli/main.js.map +1 -1
  41. package/dist/client.js +19 -2
  42. package/dist/client.js.map +1 -1
  43. package/dist/config.d.ts +70 -0
  44. package/dist/config.js +25 -1
  45. package/dist/config.js.map +1 -1
  46. package/dist/contracts.d.ts +9 -2
  47. package/dist/contracts.js +1 -1
  48. package/dist/contracts.js.map +1 -1
  49. package/dist/db/baselines/db-v26.js +8 -4
  50. package/dist/db/baselines/db-v26.js.map +1 -1
  51. package/dist/db/baselines/db-v27.d.ts +42 -0
  52. package/dist/db/baselines/db-v27.js +47 -0
  53. package/dist/db/baselines/db-v27.js.map +1 -0
  54. package/dist/db/baselines/index.js +2 -1
  55. package/dist/db/baselines/index.js.map +1 -1
  56. package/dist/db/locate.d.ts +4 -1
  57. package/dist/db/locate.js +3 -1
  58. package/dist/db/locate.js.map +1 -1
  59. package/dist/db/schema.d.ts +1 -1
  60. package/dist/db/schema.js +9 -0
  61. package/dist/db/schema.js.map +1 -1
  62. package/dist/deputy/bridge-worker.d.ts +1 -0
  63. package/dist/deputy/bridge-worker.js +79 -0
  64. package/dist/deputy/bridge-worker.js.map +1 -0
  65. package/dist/deputy/bridge.d.ts +14 -0
  66. package/dist/deputy/bridge.js +70 -0
  67. package/dist/deputy/bridge.js.map +1 -0
  68. package/dist/deputy/client.d.ts +19 -0
  69. package/dist/deputy/client.js +104 -0
  70. package/dist/deputy/client.js.map +1 -0
  71. package/dist/deputy/db-facade.d.ts +18 -0
  72. package/dist/deputy/db-facade.js +71 -0
  73. package/dist/deputy/db-facade.js.map +1 -0
  74. package/dist/deputy/files.d.ts +1 -0
  75. package/dist/deputy/files.js +16 -0
  76. package/dist/deputy/files.js.map +1 -0
  77. package/dist/deputy/install.d.ts +105 -0
  78. package/dist/deputy/install.js +467 -0
  79. package/dist/deputy/install.js.map +1 -0
  80. package/dist/deputy/notice.d.ts +6 -0
  81. package/dist/deputy/notice.js +28 -0
  82. package/dist/deputy/notice.js.map +1 -0
  83. package/dist/deputy/notices.d.ts +31 -0
  84. package/dist/deputy/notices.js +94 -0
  85. package/dist/deputy/notices.js.map +1 -0
  86. package/dist/deputy/osa.d.ts +21 -0
  87. package/dist/deputy/osa.js +72 -0
  88. package/dist/deputy/osa.js.map +1 -0
  89. package/dist/deputy/protocol.d.ts +86 -0
  90. package/dist/deputy/protocol.js +106 -0
  91. package/dist/deputy/protocol.js.map +1 -0
  92. package/dist/deputy/routing.d.ts +63 -0
  93. package/dist/deputy/routing.js +338 -0
  94. package/dist/deputy/routing.js.map +1 -0
  95. package/dist/deputy/shortcuts-exec.d.ts +10 -0
  96. package/dist/deputy/shortcuts-exec.js +50 -0
  97. package/dist/deputy/shortcuts-exec.js.map +1 -0
  98. package/dist/diagnose.d.ts +43 -1
  99. package/dist/diagnose.js +64 -2
  100. package/dist/diagnose.js.map +1 -1
  101. package/dist/index.d.ts +16 -2
  102. package/dist/index.js +17 -1
  103. package/dist/index.js.map +1 -1
  104. package/dist/mcp/server.js +25 -7
  105. package/dist/mcp/server.js.map +1 -1
  106. package/dist/model/mappers.d.ts +12 -3
  107. package/dist/model/mappers.js +6 -1
  108. package/dist/model/mappers.js.map +1 -1
  109. package/dist/model/occurrences.d.ts +14 -1
  110. package/dist/model/occurrences.js +20 -4
  111. package/dist/model/occurrences.js.map +1 -1
  112. package/dist/model/recurrence.d.ts +64 -9
  113. package/dist/model/recurrence.js +94 -19
  114. package/dist/model/recurrence.js.map +1 -1
  115. package/dist/model/template-projection.d.ts +26 -0
  116. package/dist/model/template-projection.js +113 -0
  117. package/dist/model/template-projection.js.map +1 -0
  118. package/dist/op-result.d.ts +46 -0
  119. package/dist/op-result.js +156 -0
  120. package/dist/op-result.js.map +1 -0
  121. package/dist/paths.d.ts +8 -0
  122. package/dist/paths.js +10 -0
  123. package/dist/paths.js.map +1 -1
  124. package/dist/read/detail.js +7 -1
  125. package/dist/read/detail.js.map +1 -1
  126. package/dist/read/queries.js +8 -1
  127. package/dist/read/queries.js.map +1 -1
  128. package/dist/read/views.d.ts +6 -4
  129. package/dist/read/views.js +60 -18
  130. package/dist/read/views.js.map +1 -1
  131. package/dist/sync-health.js +8 -3
  132. package/dist/sync-health.js.map +1 -1
  133. package/dist/trace/tracer.d.ts +87 -0
  134. package/dist/trace/tracer.js +167 -0
  135. package/dist/trace/tracer.js.map +1 -0
  136. package/dist/write/accessibility-probe.js +4 -2
  137. package/dist/write/accessibility-probe.js.map +1 -1
  138. package/dist/write/automation-probe.js +5 -1
  139. package/dist/write/automation-probe.js.map +1 -1
  140. package/dist/write/availability.js +7 -3
  141. package/dist/write/availability.js.map +1 -1
  142. package/dist/write/clear-reminder.js +24 -21
  143. package/dist/write/clear-reminder.js.map +1 -1
  144. package/dist/write/commands.js +95 -45
  145. package/dist/write/commands.js.map +1 -1
  146. package/dist/write/completion-context.d.ts +57 -0
  147. package/dist/write/completion-context.js +49 -0
  148. package/dist/write/completion-context.js.map +1 -0
  149. package/dist/write/experimental.d.ts +19 -0
  150. package/dist/write/experimental.js +50 -0
  151. package/dist/write/experimental.js.map +1 -1
  152. package/dist/write/failure-hints.d.ts +10 -1
  153. package/dist/write/failure-hints.js +8 -0
  154. package/dist/write/failure-hints.js.map +1 -1
  155. package/dist/write/guards.d.ts +1 -1
  156. package/dist/write/guards.js +10 -0
  157. package/dist/write/guards.js.map +1 -1
  158. package/dist/write/move.js +14 -6
  159. package/dist/write/move.js.map +1 -1
  160. package/dist/write/operations.d.ts +40 -7
  161. package/dist/write/pipeline.d.ts +41 -0
  162. package/dist/write/pipeline.js +289 -19
  163. package/dist/write/pipeline.js.map +1 -1
  164. package/dist/write/pre-state.d.ts +1 -1
  165. package/dist/write/pre-state.js +35 -17
  166. package/dist/write/pre-state.js.map +1 -1
  167. package/dist/write/promote-clone.js +434 -19
  168. package/dist/write/promote-clone.js.map +1 -1
  169. package/dist/write/reorder.js +76 -27
  170. package/dist/write/reorder.js.map +1 -1
  171. package/dist/write/repeat-anchor.d.ts +140 -0
  172. package/dist/write/repeat-anchor.js +403 -0
  173. package/dist/write/repeat-anchor.js.map +1 -0
  174. package/dist/write/repeat-asserts.d.ts +28 -0
  175. package/dist/write/repeat-asserts.js +169 -0
  176. package/dist/write/repeat-asserts.js.map +1 -0
  177. package/dist/write/repeat-rule.js +37 -12
  178. package/dist/write/repeat-rule.js.map +1 -1
  179. package/dist/write/vectors/applescript.js +7 -16
  180. package/dist/write/vectors/applescript.js.map +1 -1
  181. package/dist/write/vectors/session-reachability.d.ts +133 -0
  182. package/dist/write/vectors/session-reachability.js +151 -0
  183. package/dist/write/vectors/session-reachability.js.map +1 -0
  184. package/dist/write/vectors/shortcuts.js +5 -14
  185. package/dist/write/vectors/shortcuts.js.map +1 -1
  186. package/dist/write/vectors/simulator.d.ts +16 -1
  187. package/dist/write/vectors/simulator.js +122 -32
  188. package/dist/write/vectors/simulator.js.map +1 -1
  189. package/dist/write/vectors/types.d.ts +183 -10
  190. package/dist/write/vectors/ui-recipes.d.ts +8 -0
  191. package/dist/write/vectors/ui-recipes.js +246 -58
  192. package/dist/write/vectors/ui-recipes.js.map +1 -1
  193. package/dist/write/vectors/ui.d.ts +156 -14
  194. package/dist/write/vectors/ui.js +846 -84
  195. package/dist/write/vectors/ui.js.map +1 -1
  196. package/dist/write/verify/delta.d.ts +12 -14
  197. package/dist/write/verify/delta.js +35 -34
  198. package/dist/write/verify/delta.js.map +1 -1
  199. package/dist/write/verify/poller.d.ts +13 -0
  200. package/dist/write/verify/poller.js +38 -0
  201. package/dist/write/verify/poller.js.map +1 -1
  202. package/package.json +8 -1
  203. package/schema/envelope.schema.json +22 -0
  204. package/scripts/build-helpers.sh +104 -0
  205. package/scripts/deputy-cert-setup.sh +60 -0
  206. package/skills/things-cli/SKILL.md +11 -3
  207. package/skills/things-cli/references/errors.md +4 -0
  208. package/skills/things-cli/references/repeating.md +69 -0
@@ -0,0 +1,491 @@
1
+ /**
2
+ * things-reader — the sandboxed file half of the deputy pair.
3
+ *
4
+ * Serves the wire protocol's FILE verbs only (hello / sql / read-file /
5
+ * locate) from inside the App Sandbox, reaching the Things group container
6
+ * exclusively through a user-granted security-scoped bookmark. The scope is
7
+ * enforced by the OS: without the bookmark the sandbox denies the container
8
+ * outright, and nothing in this process can read outside the granted
9
+ * directory (plus its own container home). The unsandboxed things-deputy
10
+ * keeps the automation verbs (osascript / shortcuts).
11
+ *
12
+ * Why this exists (SANDBOX1, docs/lab/sandbox1-scoped-reader.md): the
13
+ * kTCCServiceSystemPolicyAppData class issues allow-once-per-process grants —
14
+ * unusable headlessly — and the only unsandboxed durable alternative is Full
15
+ * Disk Access. A powerbox-granted bookmark is durable across processes,
16
+ * reboots, and rebuilds under the same signing identity, and is scoped to
17
+ * exactly one directory.
18
+ *
19
+ * Ships as a minimal .app bundle (secinit refuses bare executables) signed
20
+ * with a real certificate chain (amfid refuses ad-hoc on sandboxed code).
21
+ * State (bookmark, token, socket, log) lives in the sandbox container home.
22
+ *
23
+ * Modes:
24
+ * --grant <startDir> present the NSOpenPanel ceremony, save the bookmark
25
+ * --serve serve the socket (launchd mode; default)
26
+ * --version print the version
27
+ */
28
+ import AppKit
29
+ import Foundation
30
+
31
+ setvbuf(stdout, nil, _IONBF, 0)
32
+
33
+ let READER_PROTOCOL_VERSION = 1
34
+ let MAX_LINE_BYTES = 8 * 1024 * 1024
35
+ let MAX_FILE_READ_BYTES = 64 * 1024 * 1024
36
+
37
+ // Sandboxed home == the container Data dir — the durable private store.
38
+ let home = NSHomeDirectory()
39
+ let bookmarkFile = home + "/things-reader.bookmark"
40
+ let socketPath = home + "/reader.sock"
41
+ let tokenFile = home + "/token"
42
+ let logFile = home + "/reader.log"
43
+
44
+ func stderrLine(_ message: String) {
45
+ FileHandle.standardError.write(Data("things-reader: \(message)\n".utf8))
46
+ }
47
+
48
+ // --- grant ceremony ---
49
+
50
+ func runGrant(startDir: String) -> Never {
51
+ let app = NSApplication.shared
52
+ app.setActivationPolicy(.regular)
53
+ let panel = NSOpenPanel()
54
+ panel.canChooseDirectories = true
55
+ panel.canChooseFiles = false
56
+ panel.allowsMultipleSelection = false
57
+ // The CLI passes the Things data folder itself when it exists, so the panel
58
+ // opens INSIDE it and the accept button (which returns the displayed
59
+ // directory when nothing is selected) is the only click needed.
60
+ panel.directoryURL = URL(fileURLWithPath: startDir)
61
+ panel.message =
62
+ "Click \u{201C}Grant read access\u{201D} to give things-reader read-only access to this folder — your Things data."
63
+ panel.prompt = "Grant read access"
64
+ app.activate(ignoringOtherApps: true)
65
+ guard panel.runModal() == .OK, let url = panel.url else {
66
+ print("GRANT-CANCELED")
67
+ exit(1)
68
+ }
69
+ do {
70
+ let bookmark = try url.bookmarkData(
71
+ options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)
72
+ try bookmark.write(to: URL(fileURLWithPath: bookmarkFile))
73
+ print("GRANT-OK \(url.path)")
74
+ exit(0)
75
+ } catch {
76
+ print("GRANT-FAILED \(error.localizedDescription)")
77
+ exit(1)
78
+ }
79
+ }
80
+
81
+ // --- scope resolution (re-checked per request so a fresh grant — or a
82
+ // corrective RE-grant after selecting the wrong folder — needs no restart) ---
83
+
84
+ final class Scope {
85
+ private let lock = NSLock()
86
+ private var url: URL?
87
+ private var bookmarkBytes: Data?
88
+
89
+ /// Resolve the security scope from the bookmark file, re-resolving whenever
90
+ /// the file's bytes change (a re-grant replaces them). nil = no grant.
91
+ func current() -> URL? {
92
+ lock.lock()
93
+ defer { lock.unlock() }
94
+ guard let data = FileManager.default.contents(atPath: bookmarkFile) else {
95
+ if let old = url {
96
+ old.stopAccessingSecurityScopedResource()
97
+ url = nil
98
+ bookmarkBytes = nil
99
+ }
100
+ return nil
101
+ }
102
+ if data == bookmarkBytes { return url }
103
+ var stale = false
104
+ guard
105
+ let resolved = try? URL(
106
+ resolvingBookmarkData: data, options: [.withSecurityScope],
107
+ relativeTo: nil, bookmarkDataIsStale: &stale),
108
+ resolved.startAccessingSecurityScopedResource()
109
+ else { return url }
110
+ if let old = url { old.stopAccessingSecurityScopedResource() }
111
+ var current = data
112
+ if stale,
113
+ let fresh = try? resolved.bookmarkData(
114
+ options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)
115
+ {
116
+ try? fresh.write(to: URL(fileURLWithPath: bookmarkFile))
117
+ current = fresh
118
+ }
119
+ url = resolved
120
+ bookmarkBytes = current
121
+ return resolved
122
+ }
123
+ }
124
+
125
+ let scope = Scope()
126
+
127
+ // --- server (file verbs only) ---
128
+
129
+ func loadOrCreateToken() -> String {
130
+ if let data = FileManager.default.contents(atPath: tokenFile) {
131
+ let tok = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
132
+ if !tok.isEmpty { return tok }
133
+ }
134
+ var bytes = [UInt8](repeating: 0, count: 32)
135
+ for i in 0..<bytes.count { bytes[i] = UInt8.random(in: 0...255) }
136
+ let tok = bytes.map { String(format: "%02x", $0) }.joined()
137
+ FileManager.default.createFile(
138
+ atPath: tokenFile, contents: Data(tok.utf8), attributes: [.posixPermissions: 0o600])
139
+ return tok
140
+ }
141
+
142
+ /// How long a shutting-down reader waits for requests already in flight (the
143
+ /// deputy's DRAIN_TIMEOUT_SECONDS; the two halves are booted out together).
144
+ let READER_DRAIN_TIMEOUT_SECONDS: TimeInterval = 10
145
+
146
+ final class ReaderServer {
147
+ let token: String
148
+ let startedAt = Date()
149
+ private let dbQueue = DispatchQueue(label: "things-reader.db")
150
+ private let logQueue = DispatchQueue(label: "things-reader.log")
151
+ private var listenFd: Int32 = -1
152
+ /// Requests between dispatch and their written response; the drain waits on it.
153
+ private let inflightCond = NSCondition()
154
+ private var inflight = 0
155
+ private var reader: SqliteReader? // guarded by dbQueue
156
+ private var readerRoot: String? // scope the open handle belongs to; guarded by dbQueue
157
+ private let cacheLock = NSLock()
158
+ private var cachedDbPath: String?
159
+
160
+ init(token: String) {
161
+ self.token = token
162
+ }
163
+
164
+ private func readCachedDbPath() -> String? {
165
+ cacheLock.lock()
166
+ defer { cacheLock.unlock() }
167
+ return cachedDbPath
168
+ }
169
+
170
+ private func storeCachedDbPath(_ path: String) {
171
+ cacheLock.lock()
172
+ cachedDbPath = path
173
+ cacheLock.unlock()
174
+ }
175
+
176
+ func run() {
177
+ let fd = socket(AF_UNIX, SOCK_STREAM, 0)
178
+ guard fd >= 0 else { fatalDie("socket() errno \(errno)") }
179
+ listenFd = fd
180
+ unlink(socketPath)
181
+ var addr = sockaddr_un()
182
+ addr.sun_family = sa_family_t(AF_UNIX)
183
+ let capacity = MemoryLayout.size(ofValue: addr.sun_path)
184
+ guard socketPath.utf8.count < capacity else { fatalDie("socket path too long: \(socketPath)") }
185
+ _ = withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
186
+ socketPath.withCString { cstr in
187
+ strncpy(UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self), cstr, capacity - 1)
188
+ }
189
+ }
190
+ let bound = withUnsafePointer(to: &addr) { ptr in
191
+ ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
192
+ bind(fd, sa, socklen_t(MemoryLayout<sockaddr_un>.size))
193
+ }
194
+ }
195
+ guard bound == 0 else { fatalDie("bind(\(socketPath)) errno \(errno)") }
196
+ chmod(socketPath, 0o600)
197
+ guard listen(fd, 16) == 0 else { fatalDie("listen() errno \(errno)") }
198
+ audit(["event": "started", "pid": Int(getpid()), "version": DEPUTY_VERSION])
199
+ while true {
200
+ let conn = accept(fd, nil, nil)
201
+ if conn < 0 {
202
+ if errno == EINTR { continue }
203
+ break
204
+ }
205
+ var uid: uid_t = 0
206
+ var gid: gid_t = 0
207
+ if getpeereid(conn, &uid, &gid) != 0 || uid != getuid() {
208
+ close(conn)
209
+ continue
210
+ }
211
+ let thread = Thread { [weak self] in self?.handleConnection(conn) }
212
+ thread.name = "things-reader.conn"
213
+ thread.start()
214
+ }
215
+ }
216
+
217
+ private func fatalDie(_ message: String) -> Never {
218
+ stderrLine(message)
219
+ exit(1)
220
+ }
221
+
222
+ /**
223
+ * Graceful drain, then teardown (mirrors the deputy): remove the socket path
224
+ * so no new client can find us, close the listener, wait out the in-flight
225
+ * requests within a bound, flush the log. SIGKILL is still the hard stop.
226
+ */
227
+ func drainAndShutdown(timeout: TimeInterval = READER_DRAIN_TIMEOUT_SECONDS) {
228
+ unlink(socketPath)
229
+ if listenFd >= 0 {
230
+ Darwin.shutdown(listenFd, SHUT_RDWR)
231
+ close(listenFd)
232
+ listenFd = -1
233
+ }
234
+ let deadline = Date().addingTimeInterval(timeout)
235
+ inflightCond.lock()
236
+ while inflight > 0 && Date() < deadline {
237
+ _ = inflightCond.wait(until: deadline)
238
+ }
239
+ let remaining = inflight
240
+ inflightCond.unlock()
241
+ audit(["event": "stopped", "drained": remaining == 0, "inflight": remaining])
242
+ logQueue.sync {}
243
+ }
244
+
245
+ private func beginRequest() {
246
+ inflightCond.lock()
247
+ inflight += 1
248
+ inflightCond.unlock()
249
+ }
250
+
251
+ private func endRequest() {
252
+ inflightCond.lock()
253
+ inflight -= 1
254
+ if inflight == 0 { inflightCond.broadcast() }
255
+ inflightCond.unlock()
256
+ }
257
+
258
+ private func handleConnection(_ conn: Int32) {
259
+ defer { close(conn) }
260
+ var buffer = Data()
261
+ var chunk = [UInt8](repeating: 0, count: 65536)
262
+ while true {
263
+ let n = read(conn, &chunk, chunk.count)
264
+ if n <= 0 { return }
265
+ buffer.append(contentsOf: chunk[0..<n])
266
+ if buffer.count > MAX_LINE_BYTES { return }
267
+ while let nl = buffer.firstIndex(of: 0x0A) {
268
+ let line = buffer.subdata(in: buffer.startIndex..<nl)
269
+ buffer.removeSubrange(buffer.startIndex...nl)
270
+ // In flight from dispatch until the response is on the wire.
271
+ beginRequest()
272
+ let response = dispatch(line: line)
273
+ guard var data = try? JSONSerialization.data(withJSONObject: response) else {
274
+ endRequest()
275
+ return
276
+ }
277
+ data.append(0x0A)
278
+ let ok = data.withUnsafeBytes { raw -> Bool in
279
+ var offset = 0
280
+ while offset < raw.count {
281
+ let written = write(conn, raw.baseAddress!.advanced(by: offset), raw.count - offset)
282
+ if written <= 0 { return false }
283
+ offset += written
284
+ }
285
+ return true
286
+ }
287
+ endRequest()
288
+ if !ok { return }
289
+ }
290
+ }
291
+ }
292
+
293
+ private func errorResponse(id: Any?, code: String, message: String) -> [String: Any] {
294
+ ["id": id ?? NSNull(), "ok": false, "error": ["code": code, "message": message]]
295
+ }
296
+
297
+ private func dispatch(line: Data) -> [String: Any] {
298
+ guard let obj = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any] else {
299
+ return errorResponse(id: nil, code: "bad-request", message: "request is not a JSON object")
300
+ }
301
+ let id = obj["id"]
302
+ guard let v = obj["v"] as? Int, v == READER_PROTOCOL_VERSION else {
303
+ return errorResponse(id: id, code: "unsupported-protocol", message: "reader speaks protocol \(READER_PROTOCOL_VERSION)")
304
+ }
305
+ guard let reqToken = obj["token"] as? String, reqToken == token else {
306
+ return errorResponse(id: id, code: "bad-token", message: "request token does not match the reader token file")
307
+ }
308
+ guard let verb = obj["verb"] as? String else {
309
+ return errorResponse(id: id, code: "bad-request", message: "missing verb")
310
+ }
311
+ let started = Date()
312
+ let result: [String: Any]
313
+ switch verb {
314
+ case "hello":
315
+ result = [
316
+ "id": id ?? NSNull(), "ok": true,
317
+ "protocol": READER_PROTOCOL_VERSION,
318
+ "deputyVersion": DEPUTY_VERSION,
319
+ "role": "reader",
320
+ "pid": Int(getpid()),
321
+ "granted": scope.current() != nil,
322
+ "dbPath": readCachedDbPath() ?? NSNull(),
323
+ "uptimeMs": Int(Date().timeIntervalSince(startedAt) * 1000),
324
+ ]
325
+ case "locate":
326
+ result = handleLocate(id: id)
327
+ case "sql":
328
+ guard let sql = obj["sql"] as? String else {
329
+ result = errorResponse(id: id, code: "bad-request", message: "sql verb requires a sql string")
330
+ break
331
+ }
332
+ let params = obj["params"] as? [Any] ?? []
333
+ result = dbQueue.sync { handleSql(id: id, sql: sql, params: params) }
334
+ case "read-file":
335
+ guard let path = obj["path"] as? String else {
336
+ result = errorResponse(id: id, code: "bad-request", message: "read-file verb requires a path string")
337
+ break
338
+ }
339
+ result = handleReadFile(id: id, path: path)
340
+ default:
341
+ // The automation verbs live on things-deputy — never here, by design.
342
+ result = errorResponse(id: id, code: "unsupported-verb", message: "the reader serves file verbs only (hello/sql/read-file/locate)")
343
+ }
344
+ audit([
345
+ "ts": iso8601Now(), "verb": verb, "ok": (result["ok"] as? Bool) ?? false,
346
+ "ms": Int(Date().timeIntervalSince(started) * 1000),
347
+ ])
348
+ return result
349
+ }
350
+
351
+ private func scopedRoot(id: Any?) -> (URL?, [String: Any]?) {
352
+ guard let root = scope.current() else {
353
+ return (
354
+ nil,
355
+ errorResponse(
356
+ id: id, code: "not-granted",
357
+ message: "no security-scoped grant yet — run `things helpers grant` and select the Things data folder")
358
+ )
359
+ }
360
+ return (root, nil)
361
+ }
362
+
363
+ private func locateCandidates(root: URL) -> [String] {
364
+ let fm = FileManager.default
365
+ guard let entries = try? fm.contentsOfDirectory(atPath: root.path) else { return [] }
366
+ var found: [(String, Date)] = []
367
+ for entry in entries where entry.hasPrefix("ThingsData-") {
368
+ let dbPath = root.path + "/" + entry + "/Things Database.thingsdatabase/main.sqlite"
369
+ if let attrs = try? fm.attributesOfItem(atPath: dbPath),
370
+ let mtime = attrs[.modificationDate] as? Date
371
+ {
372
+ found.append((dbPath, mtime))
373
+ }
374
+ }
375
+ return found.sorted { $0.1 > $1.1 }.map { $0.0 }
376
+ }
377
+
378
+ private func handleLocate(id: Any?) -> [String: Any] {
379
+ let (root, err) = scopedRoot(id: id)
380
+ guard let r = root else { return err! }
381
+ let candidates = locateCandidates(root: r)
382
+ guard let first = candidates.first else {
383
+ return errorResponse(id: id, code: "not-found", message: "Things database not found under the granted folder (\(r.path))")
384
+ }
385
+ storeCachedDbPath(first)
386
+ return ["id": id ?? NSNull(), "ok": true, "path": first, "otherCandidates": Array(candidates.dropFirst())]
387
+ }
388
+
389
+ private func handleSql(id: Any?, sql: String, params: [Any]) -> [String: Any] {
390
+ let (root, err) = scopedRoot(id: id)
391
+ guard let r = root else { return err! }
392
+ // A re-grant moves the scope: drop a handle opened under the old root so
393
+ // queries never keep answering from a folder the user has replaced.
394
+ if reader != nil && readerRoot != r.path { reader = nil }
395
+ if reader == nil {
396
+ guard let dbPath = locateCandidates(root: r).first else {
397
+ return errorResponse(id: id, code: "not-found", message: "no Things database under the granted folder")
398
+ }
399
+ do {
400
+ reader = try SqliteReader(path: dbPath)
401
+ readerRoot = r.path
402
+ storeCachedDbPath(dbPath)
403
+ } catch {
404
+ return errorResponse(id: id, code: "sql-error", message: "cannot open \(dbPath) read-only: \(error.localizedDescription)")
405
+ }
406
+ }
407
+ do {
408
+ let rows = try reader!.query(sql: sql, params: params)
409
+ return ["id": id ?? NSNull(), "ok": true, "rows": rows]
410
+ } catch let err as SqliteError {
411
+ return errorResponse(id: id, code: "sql-error", message: err.message)
412
+ } catch {
413
+ return errorResponse(id: id, code: "sql-error", message: error.localizedDescription)
414
+ }
415
+ }
416
+
417
+ private func handleReadFile(id: Any?, path: String) -> [String: Any] {
418
+ let (root, err) = scopedRoot(id: id)
419
+ guard let r = root else { return err! }
420
+ let canonical = URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path
421
+ let canonicalRoot = r.resolvingSymlinksInPath().standardizedFileURL.path
422
+ guard canonical == canonicalRoot || canonical.hasPrefix(canonicalRoot + "/") else {
423
+ return errorResponse(id: id, code: "read-denied", message: "path is outside the granted folder (\(canonicalRoot))")
424
+ }
425
+ guard let data = FileManager.default.contents(atPath: canonical) else {
426
+ return errorResponse(id: id, code: "not-found", message: "cannot read \(canonical)")
427
+ }
428
+ guard data.count <= MAX_FILE_READ_BYTES else {
429
+ return errorResponse(id: id, code: "too-large", message: "file exceeds \(MAX_FILE_READ_BYTES) bytes")
430
+ }
431
+ return ["id": id ?? NSNull(), "ok": true, "b64": data.base64EncodedString()]
432
+ }
433
+
434
+ private func audit(_ entry: [String: Any]) {
435
+ var withTs = entry
436
+ if withTs["ts"] == nil { withTs["ts"] = iso8601Now() }
437
+ guard var data = try? JSONSerialization.data(withJSONObject: withTs) else { return }
438
+ data.append(0x0A)
439
+ logQueue.async {
440
+ if let handle = FileHandle(forWritingAtPath: logFile) {
441
+ handle.seekToEndOfFile()
442
+ handle.write(data)
443
+ try? handle.close()
444
+ } else {
445
+ FileManager.default.createFile(
446
+ atPath: logFile, contents: data, attributes: [.posixPermissions: 0o600])
447
+ }
448
+ }
449
+ }
450
+ }
451
+
452
+ func iso8601Now() -> String {
453
+ ISO8601DateFormatter().string(from: Date())
454
+ }
455
+
456
+ // --- entry ---
457
+
458
+ let arguments = CommandLine.arguments
459
+ switch arguments.count >= 2 ? arguments[1] : "--serve" {
460
+ case "--version":
461
+ print(DEPUTY_VERSION)
462
+ exit(0)
463
+ case "--grant":
464
+ guard arguments.count == 3 else {
465
+ stderrLine("usage: things-reader --grant <start-directory>")
466
+ exit(2)
467
+ }
468
+ runGrant(startDir: arguments[2])
469
+ case "--serve":
470
+ let server = ReaderServer(token: loadOrCreateToken())
471
+ signal(SIGPIPE, SIG_IGN)
472
+ signal(SIGTERM, SIG_IGN)
473
+ signal(SIGINT, SIG_IGN)
474
+ let termSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
475
+ let intSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
476
+ for source in [termSource, intSource] {
477
+ source.setEventHandler {
478
+ // Graceful drain: stop accepting, finish in-flight reads within a bound,
479
+ // then remove the socket and exit cleanly (an upgrade boots both halves
480
+ // out mid-flight).
481
+ server.drainAndShutdown()
482
+ exit(0)
483
+ }
484
+ source.resume()
485
+ }
486
+ Thread.detachNewThread { server.run() }
487
+ dispatchMain()
488
+ default:
489
+ stderrLine("usage: things-reader --serve | --grant <dir> | --version")
490
+ exit(2)
491
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * things-deputy — the TCC permission broker for the things CLI.
3
+ *
4
+ * A deliberately dumb, launchd-supervised proxy: it executes read-only SQL
5
+ * against the Things database, runs osascript with fixed argument shapes, and
6
+ * reads files inside the Things group container — nothing else. All product
7
+ * logic (validation, guards, verification, audit) stays in the TypeScript
8
+ * library; the deputy exists solely so macOS TCC grants (Automation,
9
+ * Accessibility, group-container reads) attach to ONE stable signed identity
10
+ * instead of whichever agent harness happens to invoke the CLI this week.
11
+ *
12
+ * Security posture (docs/design/agent-daemon.md §β1):
13
+ * - UNIX socket + token file live in a 0700 state dir; socket and token are 0600.
14
+ * - Every connection is peer-checked (same-UID) and every request carries the token.
15
+ * - SQL runs on a SQLITE_OPEN_READONLY connection with ATTACH denied by an
16
+ * authorizer — the connection cannot write and cannot reach other files.
17
+ * - osascript is invoked at a fixed absolute path with exactly two argv shapes
18
+ * (`-e <script>` / `-l JavaScript -e <script>`); the client never supplies argv.
19
+ * - File reads are confined to the resolved Things group container subtree.
20
+ * - Every request lands in a local JSONL audit log (hashes, never content).
21
+ *
22
+ * The deputy never daemonizes itself: launchd (or a test harness) owns the
23
+ * process lifecycle. There is exactly one run mode — foreground.
24
+ */
25
+ import Foundation
26
+
27
+ let PROTOCOL_VERSION = 1
28
+ let MAX_REQUEST_BYTES = 8 * 1024 * 1024
29
+ let MAX_FILE_READ_BYTES = 64 * 1024 * 1024
30
+
31
+ struct DeputyPaths {
32
+ let stateDir: String
33
+ var socket: String { stateDir + "/deputy.sock" }
34
+ var token: String { stateDir + "/token" }
35
+ var log: String { stateDir + "/deputy.log" }
36
+ var config: String { stateDir + "/deputy.json" }
37
+ }
38
+
39
+ /// Deputy-local overrides, read once at startup from `<state-dir>/deputy.json`.
40
+ /// The state dir is 0700, so anything able to write this file already owns the
41
+ /// user account; the overrides exist for tests and non-standard installs.
42
+ struct DeputyConfig {
43
+ var osascriptPath: String?
44
+ var shortcutsPath: String?
45
+
46
+ static func load(path: String) -> DeputyConfig {
47
+ var cfg = DeputyConfig()
48
+ guard let data = FileManager.default.contents(atPath: path),
49
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
50
+ else { return cfg }
51
+ cfg.osascriptPath = obj["osascriptPath"] as? String
52
+ cfg.shortcutsPath = obj["shortcutsPath"] as? String
53
+ return cfg
54
+ }
55
+ }
56
+
57
+ func defaultStateDir() -> String {
58
+ let env = ProcessInfo.processInfo.environment
59
+ if let explicit = env["THINGS_API_STATE_DIR"], !explicit.isEmpty {
60
+ return explicit + "/deputy"
61
+ }
62
+ let base =
63
+ (env["XDG_STATE_HOME"]).flatMap { $0.isEmpty ? nil : $0 }
64
+ ?? NSHomeDirectory() + "/.local/state"
65
+ return base + "/things-api/deputy"
66
+ }
67
+
68
+ func loadOrCreateToken(path: String) -> String {
69
+ if let data = FileManager.default.contents(atPath: path) {
70
+ let tok = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
71
+ if !tok.isEmpty { return tok }
72
+ }
73
+ var bytes = [UInt8](repeating: 0, count: 32)
74
+ for i in 0..<bytes.count { bytes[i] = UInt8.random(in: 0...255) }
75
+ let tok = bytes.map { String(format: "%02x", $0) }.joined()
76
+ FileManager.default.createFile(
77
+ atPath: path, contents: Data(tok.utf8), attributes: [.posixPermissions: 0o600])
78
+ return tok
79
+ }
80
+
81
+ // --- entry ---
82
+
83
+ var stateDirArg: String? = nil
84
+ var argIter = CommandLine.arguments.dropFirst().makeIterator()
85
+ while let arg = argIter.next() {
86
+ switch arg {
87
+ case "--state-dir":
88
+ stateDirArg = argIter.next()
89
+ case "--version":
90
+ print(DEPUTY_VERSION)
91
+ exit(0)
92
+ default:
93
+ FileHandle.standardError.write(Data("things-deputy: unknown argument \(arg)\n".utf8))
94
+ exit(2)
95
+ }
96
+ }
97
+
98
+ let paths = DeputyPaths(stateDir: stateDirArg ?? defaultStateDir())
99
+ do {
100
+ try FileManager.default.createDirectory(
101
+ atPath: paths.stateDir, withIntermediateDirectories: true,
102
+ attributes: [.posixPermissions: 0o700])
103
+ } catch {
104
+ FileHandle.standardError.write(
105
+ Data("things-deputy: cannot create state dir \(paths.stateDir): \(error)\n".utf8))
106
+ exit(1)
107
+ }
108
+
109
+ let config = DeputyConfig.load(path: paths.config)
110
+ let token = loadOrCreateToken(path: paths.token)
111
+ let server = Server(paths: paths, config: config, token: token)
112
+
113
+ signal(SIGPIPE, SIG_IGN)
114
+ signal(SIGTERM, SIG_IGN)
115
+ signal(SIGINT, SIG_IGN)
116
+ let termSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
117
+ let intSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
118
+ for source in [termSource, intSource] {
119
+ source.setEventHandler {
120
+ // Graceful drain: stop accepting, let in-flight requests finish (bounded),
121
+ // then remove the socket and exit cleanly. An upgrade boots the old
122
+ // process out mid-flight, and a request in progress must not die with it.
123
+ server.drainAndShutdown()
124
+ exit(0)
125
+ }
126
+ source.resume()
127
+ }
128
+
129
+ // The accept loop runs on a background thread; the MAIN thread must sit in
130
+ // dispatchMain() so the signal sources above (queued on .main) actually fire —
131
+ // a main thread blocked in accept() would ignore SIGTERM forever.
132
+ Thread.detachNewThread { server.run() }
133
+ dispatchMain()