things-api 0.17.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.
- package/README.md +1 -1
- package/deputy/VERSION +1 -0
- package/deputy/helpers-Info.plist +23 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +20 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +115 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +23 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
- package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +125 -0
- package/deputy/reader/Info.plist +20 -0
- package/deputy/reader/entitlements.plist +15 -0
- package/deputy/reader/main.swift +491 -0
- package/deputy/src/main.swift +133 -0
- package/deputy/src/osascript.swift +102 -0
- package/deputy/src/server.swift +350 -0
- package/deputy/src/sqlite.swift +121 -0
- package/dist/cli/commands/doctor.js +55 -2
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/helpers.d.ts +17 -0
- package/dist/cli/commands/helpers.js +148 -0
- package/dist/cli/commands/helpers.js.map +1 -0
- package/dist/cli/commands/op-result.d.ts +15 -0
- package/dist/cli/commands/op-result.js +39 -0
- package/dist/cli/commands/op-result.js.map +1 -0
- package/dist/cli/commands/writes.js +22 -3
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/help.js +4 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/helpers-check.d.ts +10 -0
- package/dist/cli/helpers-check.js +59 -0
- package/dist/cli/helpers-check.js.map +1 -0
- package/dist/cli/main.js +22 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/client.js +16 -2
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +31 -0
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +1 -1
- package/dist/contracts.js +1 -1
- package/dist/db/baselines/db-v26.js +8 -4
- package/dist/db/baselines/db-v26.js.map +1 -1
- package/dist/db/baselines/db-v27.d.ts +42 -0
- package/dist/db/baselines/db-v27.js +47 -0
- package/dist/db/baselines/db-v27.js.map +1 -0
- package/dist/db/baselines/index.js +2 -1
- package/dist/db/baselines/index.js.map +1 -1
- package/dist/db/locate.d.ts +4 -1
- package/dist/db/locate.js +3 -1
- package/dist/db/locate.js.map +1 -1
- package/dist/db/schema.d.ts +1 -1
- package/dist/db/schema.js +9 -0
- package/dist/db/schema.js.map +1 -1
- package/dist/deputy/bridge-worker.d.ts +1 -0
- package/dist/deputy/bridge-worker.js +79 -0
- package/dist/deputy/bridge-worker.js.map +1 -0
- package/dist/deputy/bridge.d.ts +14 -0
- package/dist/deputy/bridge.js +70 -0
- package/dist/deputy/bridge.js.map +1 -0
- package/dist/deputy/client.d.ts +19 -0
- package/dist/deputy/client.js +104 -0
- package/dist/deputy/client.js.map +1 -0
- package/dist/deputy/db-facade.d.ts +18 -0
- package/dist/deputy/db-facade.js +71 -0
- package/dist/deputy/db-facade.js.map +1 -0
- package/dist/deputy/files.d.ts +1 -0
- package/dist/deputy/files.js +16 -0
- package/dist/deputy/files.js.map +1 -0
- package/dist/deputy/install.d.ts +105 -0
- package/dist/deputy/install.js +467 -0
- package/dist/deputy/install.js.map +1 -0
- package/dist/deputy/notice.d.ts +6 -0
- package/dist/deputy/notice.js +28 -0
- package/dist/deputy/notice.js.map +1 -0
- package/dist/deputy/notices.d.ts +31 -0
- package/dist/deputy/notices.js +94 -0
- package/dist/deputy/notices.js.map +1 -0
- package/dist/deputy/osa.d.ts +21 -0
- package/dist/deputy/osa.js +72 -0
- package/dist/deputy/osa.js.map +1 -0
- package/dist/deputy/protocol.d.ts +86 -0
- package/dist/deputy/protocol.js +106 -0
- package/dist/deputy/protocol.js.map +1 -0
- package/dist/deputy/routing.d.ts +63 -0
- package/dist/deputy/routing.js +338 -0
- package/dist/deputy/routing.js.map +1 -0
- package/dist/deputy/shortcuts-exec.d.ts +10 -0
- package/dist/deputy/shortcuts-exec.js +50 -0
- package/dist/deputy/shortcuts-exec.js.map +1 -0
- package/dist/diagnose.d.ts +43 -1
- package/dist/diagnose.js +64 -2
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +13 -2
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.js +25 -7
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/mappers.d.ts +12 -3
- package/dist/model/mappers.js +6 -1
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/recurrence.d.ts +5 -1
- package/dist/model/recurrence.js +19 -1
- package/dist/model/recurrence.js.map +1 -1
- package/dist/model/template-projection.d.ts +26 -0
- package/dist/model/template-projection.js +113 -0
- package/dist/model/template-projection.js.map +1 -0
- package/dist/op-result.d.ts +46 -0
- package/dist/op-result.js +156 -0
- package/dist/op-result.js.map +1 -0
- package/dist/read/detail.js +7 -1
- package/dist/read/detail.js.map +1 -1
- package/dist/read/queries.js +8 -1
- package/dist/read/queries.js.map +1 -1
- package/dist/read/views.d.ts +6 -4
- package/dist/read/views.js +60 -18
- package/dist/read/views.js.map +1 -1
- package/dist/sync-health.js +6 -2
- package/dist/sync-health.js.map +1 -1
- package/dist/write/accessibility-probe.js +4 -2
- package/dist/write/accessibility-probe.js.map +1 -1
- package/dist/write/automation-probe.js +5 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/availability.js +7 -3
- package/dist/write/availability.js.map +1 -1
- package/dist/write/commands.js +58 -13
- package/dist/write/commands.js.map +1 -1
- package/dist/write/experimental.d.ts +19 -0
- package/dist/write/experimental.js +50 -0
- package/dist/write/experimental.js.map +1 -1
- package/dist/write/move.js +14 -6
- package/dist/write/move.js.map +1 -1
- package/dist/write/operations.d.ts +28 -4
- package/dist/write/pipeline.d.ts +2 -0
- package/dist/write/pipeline.js +59 -2
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/pre-state.js +33 -15
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/promote-clone.js +187 -19
- package/dist/write/promote-clone.js.map +1 -1
- package/dist/write/reorder.js +76 -27
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-anchor.d.ts +30 -0
- package/dist/write/repeat-anchor.js +166 -1
- package/dist/write/repeat-anchor.js.map +1 -1
- package/dist/write/repeat-rule.js +10 -0
- package/dist/write/repeat-rule.js.map +1 -1
- package/dist/write/vectors/applescript.js +7 -16
- package/dist/write/vectors/applescript.js.map +1 -1
- package/dist/write/vectors/session-reachability.d.ts +55 -0
- package/dist/write/vectors/session-reachability.js +56 -8
- package/dist/write/vectors/session-reachability.js.map +1 -1
- package/dist/write/vectors/shortcuts.js +5 -14
- package/dist/write/vectors/shortcuts.js.map +1 -1
- package/dist/write/vectors/simulator.d.ts +16 -1
- package/dist/write/vectors/simulator.js +61 -10
- package/dist/write/vectors/simulator.js.map +1 -1
- package/dist/write/vectors/types.d.ts +67 -1
- package/dist/write/vectors/ui-recipes.js +128 -38
- package/dist/write/vectors/ui-recipes.js.map +1 -1
- package/dist/write/vectors/ui.d.ts +76 -0
- package/dist/write/vectors/ui.js +443 -37
- package/dist/write/vectors/ui.js.map +1 -1
- package/dist/write/verify/poller.d.ts +13 -0
- package/dist/write/verify/poller.js +38 -0
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +8 -1
- package/scripts/build-helpers.sh +104 -0
- package/scripts/deputy-cert-setup.sh +60 -0
- package/skills/things-cli/SKILL.md +8 -3
- package/skills/things-cli/references/errors.md +4 -0
- 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()
|