things-api 0.17.0 → 0.19.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 (277) hide show
  1. package/README.md +16 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -0
  4. package/deputy/helpers-Info.plist +27 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +22 -0
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +128 -0
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +27 -0
  11. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  12. package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
  13. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +137 -0
  14. package/deputy/reader/Info.plist +22 -0
  15. package/deputy/reader/entitlements.plist +45 -0
  16. package/deputy/reader/main.swift +580 -0
  17. package/deputy/src/main.swift +133 -0
  18. package/deputy/src/osascript.swift +102 -0
  19. package/deputy/src/server.swift +373 -0
  20. package/deputy/src/sqlite.swift +121 -0
  21. package/deputy/src/tcc.swift +58 -0
  22. package/dist/audit/schema.d.ts +13 -1
  23. package/dist/audit/schema.js.map +1 -1
  24. package/dist/capability.d.ts +160 -0
  25. package/dist/capability.js +416 -0
  26. package/dist/capability.js.map +1 -0
  27. package/dist/cli/commands/doctor.js +181 -2
  28. package/dist/cli/commands/doctor.js.map +1 -1
  29. package/dist/cli/commands/helpers.d.ts +17 -0
  30. package/dist/cli/commands/helpers.js +275 -0
  31. package/dist/cli/commands/helpers.js.map +1 -0
  32. package/dist/cli/commands/op-result.d.ts +15 -0
  33. package/dist/cli/commands/op-result.js +45 -0
  34. package/dist/cli/commands/op-result.js.map +1 -0
  35. package/dist/cli/commands/reads.js +47 -1
  36. package/dist/cli/commands/reads.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +63 -83
  39. package/dist/cli/commands/setup.js.map +1 -1
  40. package/dist/cli/commands/writes.d.ts +1 -1
  41. package/dist/cli/commands/writes.js +262 -109
  42. package/dist/cli/commands/writes.js.map +1 -1
  43. package/dist/cli/dry-run.d.ts +1 -1
  44. package/dist/cli/dry-run.js +1 -1
  45. package/dist/cli/glyphs.d.ts +23 -4
  46. package/dist/cli/glyphs.js +46 -19
  47. package/dist/cli/glyphs.js.map +1 -1
  48. package/dist/cli/help.d.ts +1 -1
  49. package/dist/cli/help.js +53 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/helpers-check.d.ts +10 -0
  52. package/dist/cli/helpers-check.js +59 -0
  53. package/dist/cli/helpers-check.js.map +1 -0
  54. package/dist/cli/main.js +22 -1
  55. package/dist/cli/main.js.map +1 -1
  56. package/dist/cli/period.d.ts +3 -1
  57. package/dist/cli/period.js +1 -1
  58. package/dist/cli/period.js.map +1 -1
  59. package/dist/cli/read-driver.js +26 -1
  60. package/dist/cli/read-driver.js.map +1 -1
  61. package/dist/cli/render.d.ts +31 -4
  62. package/dist/cli/render.js +127 -15
  63. package/dist/cli/render.js.map +1 -1
  64. package/dist/client.d.ts +22 -2
  65. package/dist/client.js +52 -15
  66. package/dist/client.js.map +1 -1
  67. package/dist/config.d.ts +31 -0
  68. package/dist/config.js +9 -0
  69. package/dist/config.js.map +1 -1
  70. package/dist/contracts.d.ts +6 -2
  71. package/dist/contracts.js +1 -1
  72. package/dist/contracts.js.map +1 -1
  73. package/dist/db/baselines/db-v26.js +8 -4
  74. package/dist/db/baselines/db-v26.js.map +1 -1
  75. package/dist/db/baselines/db-v27.d.ts +42 -0
  76. package/dist/db/baselines/db-v27.js +47 -0
  77. package/dist/db/baselines/db-v27.js.map +1 -0
  78. package/dist/db/baselines/index.js +2 -1
  79. package/dist/db/baselines/index.js.map +1 -1
  80. package/dist/db/locate.d.ts +4 -1
  81. package/dist/db/locate.js +3 -1
  82. package/dist/db/locate.js.map +1 -1
  83. package/dist/db/schema.d.ts +1 -1
  84. package/dist/db/schema.js +9 -0
  85. package/dist/db/schema.js.map +1 -1
  86. package/dist/deputy/bridge-worker.d.ts +1 -0
  87. package/dist/deputy/bridge-worker.js +79 -0
  88. package/dist/deputy/bridge-worker.js.map +1 -0
  89. package/dist/deputy/bridge.d.ts +14 -0
  90. package/dist/deputy/bridge.js +70 -0
  91. package/dist/deputy/bridge.js.map +1 -0
  92. package/dist/deputy/client.d.ts +19 -0
  93. package/dist/deputy/client.js +104 -0
  94. package/dist/deputy/client.js.map +1 -0
  95. package/dist/deputy/db-facade.d.ts +18 -0
  96. package/dist/deputy/db-facade.js +71 -0
  97. package/dist/deputy/db-facade.js.map +1 -0
  98. package/dist/deputy/files.d.ts +1 -0
  99. package/dist/deputy/files.js +16 -0
  100. package/dist/deputy/files.js.map +1 -0
  101. package/dist/deputy/install.d.ts +319 -0
  102. package/dist/deputy/install.js +1239 -0
  103. package/dist/deputy/install.js.map +1 -0
  104. package/dist/deputy/notice.d.ts +6 -0
  105. package/dist/deputy/notice.js +28 -0
  106. package/dist/deputy/notice.js.map +1 -0
  107. package/dist/deputy/notices.d.ts +31 -0
  108. package/dist/deputy/notices.js +94 -0
  109. package/dist/deputy/notices.js.map +1 -0
  110. package/dist/deputy/osa.d.ts +21 -0
  111. package/dist/deputy/osa.js +72 -0
  112. package/dist/deputy/osa.js.map +1 -0
  113. package/dist/deputy/protocol.d.ts +140 -0
  114. package/dist/deputy/protocol.js +143 -0
  115. package/dist/deputy/protocol.js.map +1 -0
  116. package/dist/deputy/routing.d.ts +84 -0
  117. package/dist/deputy/routing.js +422 -0
  118. package/dist/deputy/routing.js.map +1 -0
  119. package/dist/deputy/shortcuts-exec.d.ts +10 -0
  120. package/dist/deputy/shortcuts-exec.js +50 -0
  121. package/dist/deputy/shortcuts-exec.js.map +1 -0
  122. package/dist/diagnose.d.ts +57 -2
  123. package/dist/diagnose.js +105 -3
  124. package/dist/diagnose.js.map +1 -1
  125. package/dist/direct-setup.d.ts +78 -0
  126. package/dist/direct-setup.js +484 -0
  127. package/dist/direct-setup.js.map +1 -0
  128. package/dist/host-access.d.ts +63 -0
  129. package/dist/host-access.js +165 -0
  130. package/dist/host-access.js.map +1 -0
  131. package/dist/index.d.ts +31 -5
  132. package/dist/index.js +23 -1
  133. package/dist/index.js.map +1 -1
  134. package/dist/mcp/server.d.ts +27 -1
  135. package/dist/mcp/server.js +300 -97
  136. package/dist/mcp/server.js.map +1 -1
  137. package/dist/model/mappers.d.ts +12 -3
  138. package/dist/model/mappers.js +6 -1
  139. package/dist/model/mappers.js.map +1 -1
  140. package/dist/model/recurrence.d.ts +5 -1
  141. package/dist/model/recurrence.js +19 -1
  142. package/dist/model/recurrence.js.map +1 -1
  143. package/dist/model/template-projection.d.ts +26 -0
  144. package/dist/model/template-projection.js +113 -0
  145. package/dist/model/template-projection.js.map +1 -0
  146. package/dist/op-result.d.ts +55 -0
  147. package/dist/op-result.js +161 -0
  148. package/dist/op-result.js.map +1 -0
  149. package/dist/read/detail.js +7 -1
  150. package/dist/read/detail.js.map +1 -1
  151. package/dist/read/filter-contract.d.ts +1 -1
  152. package/dist/read/filter-contract.js +4 -0
  153. package/dist/read/filter-contract.js.map +1 -1
  154. package/dist/read/queries.js +8 -1
  155. package/dist/read/queries.js.map +1 -1
  156. package/dist/read/shape.js +4 -0
  157. package/dist/read/shape.js.map +1 -1
  158. package/dist/read/views.d.ts +35 -4
  159. package/dist/read/views.js +120 -18
  160. package/dist/read/views.js.map +1 -1
  161. package/dist/session-grant.d.ts +48 -0
  162. package/dist/session-grant.js +170 -0
  163. package/dist/session-grant.js.map +1 -0
  164. package/dist/surface-copy.d.ts +8 -0
  165. package/dist/surface-copy.js +8 -0
  166. package/dist/surface-copy.js.map +1 -1
  167. package/dist/sync-health.js +6 -2
  168. package/dist/sync-health.js.map +1 -1
  169. package/dist/wizard.d.ts +58 -0
  170. package/dist/wizard.js +167 -0
  171. package/dist/wizard.js.map +1 -0
  172. package/dist/write/accessibility-probe.js +4 -2
  173. package/dist/write/accessibility-probe.js.map +1 -1
  174. package/dist/write/automation-probe.js +5 -1
  175. package/dist/write/automation-probe.js.map +1 -1
  176. package/dist/write/availability.d.ts +1 -1
  177. package/dist/write/availability.js +10 -6
  178. package/dist/write/availability.js.map +1 -1
  179. package/dist/write/batch.d.ts +20 -0
  180. package/dist/write/batch.js +94 -15
  181. package/dist/write/batch.js.map +1 -1
  182. package/dist/write/capabilities.d.ts +9 -0
  183. package/dist/write/capabilities.js +2 -0
  184. package/dist/write/capabilities.js.map +1 -1
  185. package/dist/write/clear-reminder.js +1 -1
  186. package/dist/write/clear-reminder.js.map +1 -1
  187. package/dist/write/commands.d.ts +12 -1
  188. package/dist/write/commands.js +170 -237
  189. package/dist/write/commands.js.map +1 -1
  190. package/dist/write/experimental.d.ts +19 -0
  191. package/dist/write/experimental.js +50 -0
  192. package/dist/write/experimental.js.map +1 -1
  193. package/dist/write/failure-hints.d.ts +3 -1
  194. package/dist/write/failure-hints.js.map +1 -1
  195. package/dist/write/guards.js +19 -2
  196. package/dist/write/guards.js.map +1 -1
  197. package/dist/write/lock.d.ts +16 -13
  198. package/dist/write/lock.js +55 -0
  199. package/dist/write/lock.js.map +1 -1
  200. package/dist/write/make-repeating-project.js +9 -13
  201. package/dist/write/make-repeating-project.js.map +1 -1
  202. package/dist/write/move.js +14 -6
  203. package/dist/write/move.js.map +1 -1
  204. package/dist/write/operations.d.ts +64 -25
  205. package/dist/write/operations.js +7 -0
  206. package/dist/write/operations.js.map +1 -1
  207. package/dist/write/opid.d.ts +14 -0
  208. package/dist/write/opid.js +22 -0
  209. package/dist/write/opid.js.map +1 -1
  210. package/dist/write/param-schema.d.ts +124 -0
  211. package/dist/write/param-schema.js +747 -0
  212. package/dist/write/param-schema.js.map +1 -0
  213. package/dist/write/pipeline.d.ts +78 -2
  214. package/dist/write/pipeline.js +249 -4
  215. package/dist/write/pipeline.js.map +1 -1
  216. package/dist/write/pre-state.d.ts +8 -0
  217. package/dist/write/pre-state.js +62 -17
  218. package/dist/write/pre-state.js.map +1 -1
  219. package/dist/write/promote-clone.js +635 -367
  220. package/dist/write/promote-clone.js.map +1 -1
  221. package/dist/write/reorder.js +95 -29
  222. package/dist/write/reorder.js.map +1 -1
  223. package/dist/write/repeat-anchor.d.ts +30 -0
  224. package/dist/write/repeat-anchor.js +166 -1
  225. package/dist/write/repeat-anchor.js.map +1 -1
  226. package/dist/write/repeat-rule.d.ts +28 -1
  227. package/dist/write/repeat-rule.js +64 -4
  228. package/dist/write/repeat-rule.js.map +1 -1
  229. package/dist/write/resolution-timestamps.d.ts +11 -0
  230. package/dist/write/resolution-timestamps.js +83 -38
  231. package/dist/write/resolution-timestamps.js.map +1 -1
  232. package/dist/write/reversibility.js +4 -0
  233. package/dist/write/reversibility.js.map +1 -1
  234. package/dist/write/template-mutation.d.ts +137 -0
  235. package/dist/write/template-mutation.js +451 -0
  236. package/dist/write/template-mutation.js.map +1 -0
  237. package/dist/write/undo.js +10 -20
  238. package/dist/write/undo.js.map +1 -1
  239. package/dist/write/update-fields.d.ts +151 -0
  240. package/dist/write/update-fields.js +427 -0
  241. package/dist/write/update-fields.js.map +1 -0
  242. package/dist/write/vectors/applescript.js +10 -16
  243. package/dist/write/vectors/applescript.js.map +1 -1
  244. package/dist/write/vectors/registry.d.ts +1 -1
  245. package/dist/write/vectors/registry.js +10 -2
  246. package/dist/write/vectors/registry.js.map +1 -1
  247. package/dist/write/vectors/session-reachability.d.ts +55 -0
  248. package/dist/write/vectors/session-reachability.js +56 -8
  249. package/dist/write/vectors/session-reachability.js.map +1 -1
  250. package/dist/write/vectors/shortcuts.js +7 -16
  251. package/dist/write/vectors/shortcuts.js.map +1 -1
  252. package/dist/write/vectors/simulator.d.ts +16 -1
  253. package/dist/write/vectors/simulator.js +68 -12
  254. package/dist/write/vectors/simulator.js.map +1 -1
  255. package/dist/write/vectors/types.d.ts +124 -1
  256. package/dist/write/vectors/ui-recipes.d.ts +19 -0
  257. package/dist/write/vectors/ui-recipes.js +158 -38
  258. package/dist/write/vectors/ui-recipes.js.map +1 -1
  259. package/dist/write/vectors/ui.d.ts +89 -0
  260. package/dist/write/vectors/ui.js +504 -49
  261. package/dist/write/vectors/ui.js.map +1 -1
  262. package/dist/write/verify/delta.d.ts +21 -0
  263. package/dist/write/verify/delta.js.map +1 -1
  264. package/dist/write/verify/poller.d.ts +13 -0
  265. package/dist/write/verify/poller.js +38 -0
  266. package/dist/write/verify/poller.js.map +1 -1
  267. package/package.json +9 -1
  268. package/schema/envelope.schema.json +5 -3
  269. package/scripts/build-helpers.sh +109 -0
  270. package/scripts/deputy-cert-setup.sh +60 -0
  271. package/skills/things-cli/SKILL.md +12 -5
  272. package/skills/things-cli/references/contracts.md +1 -0
  273. package/skills/things-cli/references/data-model.md +3 -0
  274. package/skills/things-cli/references/errors.md +5 -1
  275. package/skills/things-cli/references/gui.md +1 -0
  276. package/skills/things-cli/references/repeating.md +75 -0
  277. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -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()
@@ -0,0 +1,102 @@
1
+ /**
2
+ * osascript execution with a hard timeout. The deputy owns the child process:
3
+ * a caller (or its whole harness) dying mid-request never orphans an
4
+ * osascript — the deputy's timer terminates it at the requested deadline
5
+ * (SIGTERM, then SIGKILL after a 2s grace). Argv shapes are fixed here; the
6
+ * client supplies only the script text, language, and timeout.
7
+ */
8
+ import CryptoKit
9
+ import Foundation
10
+
11
+ private final class Flag {
12
+ private let lock = NSLock()
13
+ private var value = false
14
+ func set() {
15
+ lock.lock()
16
+ value = true
17
+ lock.unlock()
18
+ }
19
+ func get() -> Bool {
20
+ lock.lock()
21
+ defer { lock.unlock() }
22
+ return value
23
+ }
24
+ }
25
+
26
+ func runOsascript(script: String, lang: String, timeoutMs: Int, binPath: String, id: Any?)
27
+ -> [String: Any]
28
+ {
29
+ runChildTool(
30
+ binPath: binPath,
31
+ args: lang == "javascript" ? ["-l", "JavaScript", "-e", script] : ["-e", script],
32
+ timeoutMs: timeoutMs, id: id)
33
+ }
34
+
35
+ /// Generic deadline-bounded child runner shared by the osascript and
36
+ /// shortcuts verbs. Argv is always assembled deputy-side from a fixed shape.
37
+ func runChildTool(binPath: String, args: [String], timeoutMs: Int, id: Any?) -> [String: Any] {
38
+ let process = Process()
39
+ process.executableURL = URL(fileURLWithPath: binPath)
40
+ process.arguments = args
41
+ let outPipe = Pipe()
42
+ let errPipe = Pipe()
43
+ process.standardOutput = outPipe
44
+ process.standardError = errPipe
45
+ process.standardInput = FileHandle.nullDevice
46
+
47
+ do {
48
+ try process.run()
49
+ } catch {
50
+ return [
51
+ "id": id ?? NSNull(), "ok": false,
52
+ "error": ["code": "internal", "message": "cannot launch \(binPath): \(error.localizedDescription)"],
53
+ ]
54
+ }
55
+
56
+ let timedOut = Flag()
57
+ let pid = process.processIdentifier
58
+ let killer = DispatchWorkItem {
59
+ timedOut.set()
60
+ process.terminate()
61
+ DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) {
62
+ if process.isRunning { kill(pid, SIGKILL) }
63
+ }
64
+ }
65
+ DispatchQueue.global().asyncAfter(
66
+ deadline: .now() + .milliseconds(max(1, timeoutMs)), execute: killer)
67
+
68
+ // Drain both pipes off-thread so a chatty script never deadlocks the wait.
69
+ var outData = Data()
70
+ var errData = Data()
71
+ let group = DispatchGroup()
72
+ group.enter()
73
+ DispatchQueue.global().async {
74
+ outData = outPipe.fileHandleForReading.readDataToEndOfFile()
75
+ group.leave()
76
+ }
77
+ group.enter()
78
+ DispatchQueue.global().async {
79
+ errData = errPipe.fileHandleForReading.readDataToEndOfFile()
80
+ group.leave()
81
+ }
82
+ process.waitUntilExit()
83
+ killer.cancel()
84
+ group.wait()
85
+
86
+ var result: [String: Any] = [
87
+ "id": id ?? NSNull(),
88
+ "ok": true,
89
+ "exitCode": Int(process.terminationStatus),
90
+ "stdout": String(decoding: outData, as: UTF8.self),
91
+ "stderr": String(decoding: errData, as: UTF8.self),
92
+ ]
93
+ if process.terminationReason == .uncaughtSignal {
94
+ result["signal"] = Int(process.terminationStatus)
95
+ }
96
+ if timedOut.get() { result["timedOut"] = true }
97
+ return result
98
+ }
99
+
100
+ func sha256Hex(_ text: String) -> String {
101
+ SHA256.hash(data: Data(text.utf8)).map { String(format: "%02x", $0) }.joined()
102
+ }
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Socket server: accept loop, peer verification, JSON-lines framing, request
3
+ * dispatch, and the local JSONL audit log. One thread per connection;
4
+ * osascript/shortcuts execution is funneled through one serial queue.
5
+ *
6
+ * MUTATIONS ONLY: the deputy serves the automation verbs (osascript,
7
+ * shortcuts). File verbs (sql / read-file / locate) live exclusively on the
8
+ * sandboxed things-reader — the deputy's own container access would ride the
9
+ * per-process AppData consent class (a prompt per process instance, measured
10
+ * 2026-08-21), which is precisely the churn this pair exists to end.
11
+ */
12
+ import Foundation
13
+
14
+ /// How long a shutting-down deputy waits for requests already in flight. An
15
+ /// upgrade (`things helpers setup` / `restart`) boots the old process out
16
+ /// mid-flight; without a drain the in-flight request dies with it. launchd's
17
+ /// own bootout wait, and the CLI's `launchctl` timeout, both clear this bound.
18
+ let DRAIN_TIMEOUT_SECONDS: TimeInterval = 10
19
+
20
+ final class Server {
21
+ let paths: DeputyPaths
22
+ let config: DeputyConfig
23
+ let token: String
24
+ let startedAt = Date()
25
+ private let osaQueue = DispatchQueue(label: "things-deputy.osa")
26
+ private let logQueue = DispatchQueue(label: "things-deputy.log")
27
+ private var listenFd: Int32 = -1
28
+ /// Requests between dispatch and their written response. Guarded by
29
+ /// `inflightCond`, which the drain waits on.
30
+ private let inflightCond = NSCondition()
31
+ private var inflight = 0
32
+
33
+ init(paths: DeputyPaths, config: DeputyConfig, token: String) {
34
+ self.paths = paths
35
+ self.config = config
36
+ self.token = token
37
+ }
38
+
39
+ func run() {
40
+ let fd = socket(AF_UNIX, SOCK_STREAM, 0)
41
+ guard fd >= 0 else { fatalDie("socket() failed: errno \(errno)") }
42
+ listenFd = fd
43
+
44
+ let sockPath = paths.socket
45
+ guard sockPath.utf8.count <= 103 else {
46
+ fatalDie("socket path too long for sockaddr_un (\(sockPath.utf8.count) bytes): \(sockPath)")
47
+ }
48
+ unlink(sockPath)
49
+ var addr = sockaddr_un()
50
+ addr.sun_family = sa_family_t(AF_UNIX)
51
+ let pathCapacity = MemoryLayout.size(ofValue: addr.sun_path)
52
+ _ = withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
53
+ sockPath.withCString { cstr in
54
+ strncpy(
55
+ UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self), cstr,
56
+ pathCapacity - 1)
57
+ }
58
+ }
59
+ let bindResult = withUnsafePointer(to: &addr) { ptr in
60
+ ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
61
+ bind(fd, sa, socklen_t(MemoryLayout<sockaddr_un>.size))
62
+ }
63
+ }
64
+ guard bindResult == 0 else { fatalDie("bind(\(sockPath)) failed: errno \(errno)") }
65
+ chmod(sockPath, 0o600)
66
+ guard listen(fd, 16) == 0 else { fatalDie("listen() failed: errno \(errno)") }
67
+
68
+ audit(["event": "started", "pid": Int(getpid()), "version": DEPUTY_VERSION])
69
+
70
+ while true {
71
+ let conn = accept(fd, nil, nil)
72
+ if conn < 0 {
73
+ if errno == EINTR { continue }
74
+ break
75
+ }
76
+ var uid: uid_t = 0
77
+ var gid: gid_t = 0
78
+ if getpeereid(conn, &uid, &gid) != 0 || uid != getuid() {
79
+ audit(["event": "rejected-peer", "uid": Int(uid)])
80
+ close(conn)
81
+ continue
82
+ }
83
+ let thread = Thread { [weak self] in self?.handleConnection(conn) }
84
+ thread.name = "things-deputy.conn"
85
+ thread.start()
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Graceful drain, then teardown. Order matters:
91
+ *
92
+ * 1. unlink the socket path FIRST — a new client cannot even find us, which
93
+ * is deterministic where waking a thread blocked in accept() is not.
94
+ * 2. close the listener so no further connection is accepted.
95
+ * 3. wait (bounded) for requests already dispatched to write their response.
96
+ * 4. flush the audit log and return; the caller exits 0.
97
+ *
98
+ * SIGKILL remains the hard stop — nothing here tries to survive it.
99
+ */
100
+ func drainAndShutdown(timeout: TimeInterval = DRAIN_TIMEOUT_SECONDS) {
101
+ unlink(paths.socket)
102
+ if listenFd >= 0 {
103
+ Darwin.shutdown(listenFd, SHUT_RDWR)
104
+ close(listenFd)
105
+ listenFd = -1
106
+ }
107
+ let deadline = Date().addingTimeInterval(timeout)
108
+ inflightCond.lock()
109
+ while inflight > 0 && Date() < deadline {
110
+ _ = inflightCond.wait(until: deadline)
111
+ }
112
+ let remaining = inflight
113
+ inflightCond.unlock()
114
+ audit(["event": "stopped", "drained": remaining == 0, "inflight": remaining])
115
+ // The audit log is written asynchronously; a barrier makes "stopped" the
116
+ // last thing on disk instead of a line lost to exit().
117
+ logQueue.sync {}
118
+ }
119
+
120
+ private func beginRequest() {
121
+ inflightCond.lock()
122
+ inflight += 1
123
+ inflightCond.unlock()
124
+ }
125
+
126
+ private func endRequest() {
127
+ inflightCond.lock()
128
+ inflight -= 1
129
+ if inflight == 0 { inflightCond.broadcast() }
130
+ inflightCond.unlock()
131
+ }
132
+
133
+ private func fatalDie(_ message: String) -> Never {
134
+ FileHandle.standardError.write(Data("things-deputy: \(message)\n".utf8))
135
+ exit(1)
136
+ }
137
+
138
+ // --- connection handling ---
139
+
140
+ private func handleConnection(_ conn: Int32) {
141
+ defer { close(conn) }
142
+ var peerPid: pid_t = 0
143
+ var pidLen = socklen_t(MemoryLayout<pid_t>.size)
144
+ _ = getsockopt(conn, SOL_LOCAL, LOCAL_PEERPID, &peerPid, &pidLen)
145
+
146
+ var buffer = Data()
147
+ var chunk = [UInt8](repeating: 0, count: 65536)
148
+ while true {
149
+ let n = read(conn, &chunk, chunk.count)
150
+ if n <= 0 { return }
151
+ buffer.append(contentsOf: chunk[0..<n])
152
+ if buffer.count > MAX_REQUEST_BYTES {
153
+ writeLine(conn, errorResponse(id: nil, code: "too-large", message: "request exceeds \(MAX_REQUEST_BYTES) bytes"))
154
+ return
155
+ }
156
+ while let nl = buffer.firstIndex(of: 0x0A) {
157
+ let line = buffer.subdata(in: buffer.startIndex..<nl)
158
+ buffer.removeSubrange(buffer.startIndex...nl)
159
+ // In flight from dispatch until the response is on the wire — that
160
+ // span is exactly what a drain must not cut short.
161
+ beginRequest()
162
+ let response = dispatch(line: line, peerPid: peerPid)
163
+ let written = writeLine(conn, response)
164
+ endRequest()
165
+ if !written { return }
166
+ }
167
+ }
168
+ }
169
+
170
+ @discardableResult
171
+ private func writeLine(_ conn: Int32, _ obj: [String: Any]) -> Bool {
172
+ guard var data = try? JSONSerialization.data(withJSONObject: obj) else { return false }
173
+ data.append(0x0A)
174
+ return data.withUnsafeBytes { raw in
175
+ var offset = 0
176
+ while offset < raw.count {
177
+ let n = write(conn, raw.baseAddress!.advanced(by: offset), raw.count - offset)
178
+ if n <= 0 { return false }
179
+ offset += n
180
+ }
181
+ return true
182
+ }
183
+ }
184
+
185
+ private func errorResponse(id: Any?, code: String, message: String) -> [String: Any] {
186
+ ["id": id ?? NSNull(), "ok": false, "error": ["code": code, "message": message]]
187
+ }
188
+
189
+ // --- dispatch ---
190
+
191
+ private func dispatch(line: Data, peerPid: pid_t) -> [String: Any] {
192
+ guard let obj = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any] else {
193
+ return errorResponse(id: nil, code: "bad-request", message: "request is not a JSON object")
194
+ }
195
+ let id = obj["id"]
196
+ guard let v = obj["v"] as? Int, v == PROTOCOL_VERSION else {
197
+ return errorResponse(
198
+ id: id, code: "unsupported-protocol",
199
+ message: "deputy speaks protocol \(PROTOCOL_VERSION); restart or rebuild the deputy (things helpers restart)")
200
+ }
201
+ guard let reqToken = obj["token"] as? String, reqToken == token else {
202
+ audit(["event": "rejected-token", "peerPid": Int(peerPid)])
203
+ return errorResponse(id: id, code: "bad-token", message: "request token does not match the deputy token file")
204
+ }
205
+ guard let verb = obj["verb"] as? String else {
206
+ return errorResponse(id: id, code: "bad-request", message: "missing verb")
207
+ }
208
+
209
+ let started = Date()
210
+ var result: [String: Any]
211
+ var auditExtra: [String: Any] = [:]
212
+ switch verb {
213
+ case "hello":
214
+ result = handleHello(id: id)
215
+ case "prime-ax":
216
+ result = handlePrimeAx(id: id)
217
+ case "osascript":
218
+ guard let script = obj["script"] as? String else {
219
+ result = errorResponse(id: id, code: "bad-request", message: "osascript verb requires a script string")
220
+ break
221
+ }
222
+ let lang = obj["lang"] as? String ?? "applescript"
223
+ guard lang == "applescript" || lang == "javascript" else {
224
+ result = errorResponse(id: id, code: "bad-request", message: "lang must be applescript or javascript")
225
+ break
226
+ }
227
+ let timeoutMs = obj["timeoutMs"] as? Int ?? 30_000
228
+ if let guardError = scriptGuard(script) {
229
+ audit(["event": "rejected-script", "reason": guardError, "peerPid": Int(peerPid)])
230
+ result = errorResponse(id: id, code: "script-denied", message: guardError)
231
+ break
232
+ }
233
+ auditExtra["scriptSha256"] = sha256Hex(script)
234
+ auditExtra["scriptBytes"] = script.utf8.count
235
+ result = osaQueue.sync {
236
+ runOsascript(
237
+ script: script, lang: lang, timeoutMs: timeoutMs,
238
+ binPath: config.osascriptPath ?? "/usr/bin/osascript", id: id)
239
+ }
240
+ case "shortcuts":
241
+ let op = obj["op"] as? String
242
+ let timeoutMs = obj["timeoutMs"] as? Int ?? 30_000
243
+ let bin = config.shortcutsPath ?? "/usr/bin/shortcuts"
244
+ if op == "list" {
245
+ result = osaQueue.sync { runChildTool(binPath: bin, args: ["list"], timeoutMs: timeoutMs, id: id) }
246
+ break
247
+ }
248
+ guard op == "run", let name = obj["name"] as? String,
249
+ let inputPath = obj["inputPath"] as? String,
250
+ let outputPath = obj["outputPath"] as? String
251
+ else {
252
+ result = errorResponse(
253
+ id: id, code: "bad-request",
254
+ message: "shortcuts verb requires op=list, or op=run with name/inputPath/outputPath")
255
+ break
256
+ }
257
+ // The deputy only runs this library's bundled proxy shortcuts — it is a
258
+ // paired tool, not a general shortcut runner. (Same spirit as the
259
+ // osascript shell-execution lint: a same-user process could run
260
+ // arbitrary shortcuts itself; it just cannot do so AS the deputy.)
261
+ guard name.hasPrefix("things-proxy-"), !inputPath.hasPrefix("-"), !outputPath.hasPrefix("-")
262
+ else {
263
+ audit(["event": "rejected-shortcut", "name": name, "peerPid": Int(peerPid)])
264
+ result = errorResponse(
265
+ id: id, code: "shortcut-denied",
266
+ message: "the deputy only runs bundled things-proxy-* shortcuts")
267
+ break
268
+ }
269
+ auditExtra["shortcut"] = name
270
+ result = osaQueue.sync {
271
+ runChildTool(
272
+ binPath: bin,
273
+ args: ["run", name, "--input-path", inputPath, "--output-path", outputPath],
274
+ timeoutMs: timeoutMs, id: id)
275
+ }
276
+ case "sql", "read-file", "locate":
277
+ // File verbs live exclusively on the sandboxed things-reader.
278
+ result = errorResponse(
279
+ id: id, code: "unsupported-verb",
280
+ message: "the deputy serves automation verbs only — file access rides things-reader (things helpers setup)")
281
+ default:
282
+ result = errorResponse(id: id, code: "bad-request", message: "unknown verb \(verb)")
283
+ }
284
+
285
+ var entry: [String: Any] = [
286
+ "ts": iso8601(Date()),
287
+ "verb": verb,
288
+ "peerPid": Int(peerPid),
289
+ "ok": (result["ok"] as? Bool) ?? false,
290
+ "ms": Int(Date().timeIntervalSince(started) * 1000),
291
+ ]
292
+ for (k, v) in auditExtra { entry[k] = v }
293
+ audit(entry)
294
+ return result
295
+ }
296
+
297
+ /**
298
+ * Defense-in-depth lint, not a security boundary (a same-user process that
299
+ * can read the token can also run osascript itself — what the deputy adds is
300
+ * only its OWN TCC grants). The lint refuses the constructs that would turn
301
+ * "drive the Things GUI" into "run arbitrary shell": every legitimate script
302
+ * this library generates talks AppleScript/JXA to Things and System Events
303
+ * and never shells out.
304
+ */
305
+ private func scriptGuard(_ script: String) -> String? {
306
+ let lowered = script.lowercased()
307
+ for banned in ["do shell script", "do script"] {
308
+ if lowered.contains(banned) {
309
+ return "script rejected: contains \"\(banned)\" — the deputy only brokers GUI/AppleEvent scripts, never shell execution"
310
+ }
311
+ }
312
+ return nil
313
+ }
314
+
315
+ // --- verb handlers ---
316
+
317
+ /**
318
+ * The handshake, plus the deputy's own TCC standing — Accessibility trust
319
+ * and Automation permission for each target it drives. Every field here is
320
+ * PROMPT-FREE by construction (see tcc.swift), which is what lets a status
321
+ * report and the onboarding ceremony ask "is this leg already granted?"
322
+ * without raising a dialog at whoever happens to run `things` next.
323
+ */
324
+ private func handleHello(id: Any?) -> [String: Any] {
325
+ [
326
+ "id": id ?? NSNull(),
327
+ "ok": true,
328
+ "protocol": PROTOCOL_VERSION,
329
+ "deputyVersion": DEPUTY_VERSION,
330
+ "role": "deputy",
331
+ "pid": Int(getpid()),
332
+ "uptimeMs": Int(Date().timeIntervalSince(startedAt) * 1000),
333
+ "axTrusted": accessibilityTrusted(),
334
+ "automation": [
335
+ "things": automationStatus(bundleID: "com.culturedcode.ThingsMac"),
336
+ "systemEvents": automationStatus(bundleID: "com.apple.systemevents"),
337
+ ],
338
+ ]
339
+ }
340
+
341
+ /**
342
+ * Raise the Accessibility consent dialog for the deputy's own identity. The
343
+ * grant itself is a toggle in System Settings, so the answer can only arrive
344
+ * later — the caller polls `hello`'s axTrusted. Returns the state as of now.
345
+ */
346
+ private func handlePrimeAx(id: Any?) -> [String: Any] {
347
+ ["id": id ?? NSNull(), "ok": true, "axTrusted": primeAccessibility()]
348
+ }
349
+
350
+ // --- audit log ---
351
+
352
+ private func audit(_ entry: [String: Any]) {
353
+ var withTs = entry
354
+ if withTs["ts"] == nil { withTs["ts"] = iso8601(Date()) }
355
+ guard var data = try? JSONSerialization.data(withJSONObject: withTs) else { return }
356
+ data.append(0x0A)
357
+ let path = paths.log
358
+ logQueue.async {
359
+ if let handle = FileHandle(forWritingAtPath: path) {
360
+ handle.seekToEndOfFile()
361
+ handle.write(data)
362
+ try? handle.close()
363
+ } else {
364
+ FileManager.default.createFile(
365
+ atPath: path, contents: data, attributes: [.posixPermissions: 0o600])
366
+ }
367
+ }
368
+ }
369
+ }
370
+
371
+ func iso8601(_ date: Date) -> String {
372
+ ISO8601DateFormatter().string(from: date)
373
+ }