things-api 0.18.0 → 0.19.1

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 (224) hide show
  1. package/README.md +18 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -1
  4. package/deputy/helpers-Info.plist +4 -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 +3 -1
  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 +15 -2
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +5 -1
  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 +14 -2
  14. package/deputy/reader/Info.plist +2 -0
  15. package/deputy/reader/entitlements.plist +30 -0
  16. package/deputy/reader/main.swift +129 -40
  17. package/deputy/src/server.swift +25 -2
  18. package/deputy/src/tcc.swift +58 -0
  19. package/dist/audit/schema.d.ts +30 -1
  20. package/dist/audit/schema.js.map +1 -1
  21. package/dist/capability.d.ts +322 -0
  22. package/dist/capability.js +706 -0
  23. package/dist/capability.js.map +1 -0
  24. package/dist/cli/commands/doctor.d.ts +13 -1
  25. package/dist/cli/commands/doctor.js +141 -5
  26. package/dist/cli/commands/doctor.js.map +1 -1
  27. package/dist/cli/commands/helpers.js +189 -51
  28. package/dist/cli/commands/helpers.js.map +1 -1
  29. package/dist/cli/commands/mcp.js +8 -0
  30. package/dist/cli/commands/mcp.js.map +1 -1
  31. package/dist/cli/commands/op-result.js +6 -0
  32. package/dist/cli/commands/op-result.js.map +1 -1
  33. package/dist/cli/commands/reads.js +47 -1
  34. package/dist/cli/commands/reads.js.map +1 -1
  35. package/dist/cli/commands/repeat-flags.js +34 -2
  36. package/dist/cli/commands/repeat-flags.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +76 -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 +311 -142
  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 +49 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/interrupt.d.ts +54 -6
  52. package/dist/cli/interrupt.js +78 -12
  53. package/dist/cli/interrupt.js.map +1 -1
  54. package/dist/cli/main.js +8 -5
  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 +23 -3
  65. package/dist/client.js +36 -13
  66. package/dist/client.js.map +1 -1
  67. package/dist/contracts.d.ts +8 -4
  68. package/dist/contracts.js +1 -1
  69. package/dist/contracts.js.map +1 -1
  70. package/dist/deputy/install.d.ts +234 -6
  71. package/dist/deputy/install.js +843 -31
  72. package/dist/deputy/install.js.map +1 -1
  73. package/dist/deputy/notices.js +2 -2
  74. package/dist/deputy/notices.js.map +1 -1
  75. package/dist/deputy/protocol.d.ts +59 -5
  76. package/dist/deputy/protocol.js +48 -11
  77. package/dist/deputy/protocol.js.map +1 -1
  78. package/dist/deputy/routing.d.ts +37 -0
  79. package/dist/deputy/routing.js +181 -27
  80. package/dist/deputy/routing.js.map +1 -1
  81. package/dist/deputy/wake.d.ts +61 -0
  82. package/dist/deputy/wake.js +149 -0
  83. package/dist/deputy/wake.js.map +1 -0
  84. package/dist/diagnose.d.ts +19 -4
  85. package/dist/diagnose.js +64 -8
  86. package/dist/diagnose.js.map +1 -1
  87. package/dist/direct-setup.d.ts +104 -0
  88. package/dist/direct-setup.js +658 -0
  89. package/dist/direct-setup.js.map +1 -0
  90. package/dist/host-access.d.ts +75 -0
  91. package/dist/host-access.js +177 -0
  92. package/dist/host-access.js.map +1 -0
  93. package/dist/index.d.ts +20 -5
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/mcp/server.d.ts +27 -1
  97. package/dist/mcp/server.js +308 -109
  98. package/dist/mcp/server.js.map +1 -1
  99. package/dist/op-result.d.ts +9 -0
  100. package/dist/op-result.js +13 -1
  101. package/dist/op-result.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +1 -1
  103. package/dist/read/filter-contract.js +4 -0
  104. package/dist/read/filter-contract.js.map +1 -1
  105. package/dist/read/shape.js +4 -0
  106. package/dist/read/shape.js.map +1 -1
  107. package/dist/read/views.d.ts +29 -0
  108. package/dist/read/views.js +60 -0
  109. package/dist/read/views.js.map +1 -1
  110. package/dist/session-grant.d.ts +48 -0
  111. package/dist/session-grant.js +185 -0
  112. package/dist/session-grant.js.map +1 -0
  113. package/dist/surface-copy.d.ts +19 -3
  114. package/dist/surface-copy.js +19 -3
  115. package/dist/surface-copy.js.map +1 -1
  116. package/dist/wizard.d.ts +58 -0
  117. package/dist/wizard.js +174 -0
  118. package/dist/wizard.js.map +1 -0
  119. package/dist/write/availability.d.ts +1 -16
  120. package/dist/write/availability.js +10 -65
  121. package/dist/write/availability.js.map +1 -1
  122. package/dist/write/batch.d.ts +20 -0
  123. package/dist/write/batch.js +111 -21
  124. package/dist/write/batch.js.map +1 -1
  125. package/dist/write/capabilities.d.ts +9 -0
  126. package/dist/write/capabilities.js +2 -0
  127. package/dist/write/capabilities.js.map +1 -1
  128. package/dist/write/clear-reminder.js +1 -1
  129. package/dist/write/clear-reminder.js.map +1 -1
  130. package/dist/write/commands.d.ts +22 -7
  131. package/dist/write/commands.js +231 -254
  132. package/dist/write/commands.js.map +1 -1
  133. package/dist/write/failure-hints.d.ts +32 -7
  134. package/dist/write/failure-hints.js +9 -14
  135. package/dist/write/failure-hints.js.map +1 -1
  136. package/dist/write/guards.js +22 -4
  137. package/dist/write/guards.js.map +1 -1
  138. package/dist/write/lock.d.ts +16 -13
  139. package/dist/write/lock.js +55 -0
  140. package/dist/write/lock.js.map +1 -1
  141. package/dist/write/make-repeating-project.js +9 -13
  142. package/dist/write/make-repeating-project.js.map +1 -1
  143. package/dist/write/move.js +9 -12
  144. package/dist/write/move.js.map +1 -1
  145. package/dist/write/operations.d.ts +51 -29
  146. package/dist/write/operations.js +16 -0
  147. package/dist/write/operations.js.map +1 -1
  148. package/dist/write/opid.d.ts +72 -14
  149. package/dist/write/opid.js +152 -8
  150. package/dist/write/opid.js.map +1 -1
  151. package/dist/write/param-schema.d.ts +80 -0
  152. package/dist/write/param-schema.js +754 -0
  153. package/dist/write/param-schema.js.map +1 -0
  154. package/dist/write/pipeline.d.ts +110 -5
  155. package/dist/write/pipeline.js +288 -14
  156. package/dist/write/pipeline.js.map +1 -1
  157. package/dist/write/pre-state.d.ts +38 -16
  158. package/dist/write/pre-state.js +117 -22
  159. package/dist/write/pre-state.js.map +1 -1
  160. package/dist/write/promote-clone.js +658 -444
  161. package/dist/write/promote-clone.js.map +1 -1
  162. package/dist/write/reorder.js +25 -8
  163. package/dist/write/reorder.js.map +1 -1
  164. package/dist/write/repeat-collateral.d.ts +31 -0
  165. package/dist/write/repeat-collateral.js +153 -0
  166. package/dist/write/repeat-collateral.js.map +1 -0
  167. package/dist/write/repeat-rule.d.ts +28 -1
  168. package/dist/write/repeat-rule.js +54 -4
  169. package/dist/write/repeat-rule.js.map +1 -1
  170. package/dist/write/resolution-timestamps.d.ts +11 -0
  171. package/dist/write/resolution-timestamps.js +108 -40
  172. package/dist/write/resolution-timestamps.js.map +1 -1
  173. package/dist/write/reversibility.js +4 -0
  174. package/dist/write/reversibility.js.map +1 -1
  175. package/dist/write/template-mutation.d.ts +137 -0
  176. package/dist/write/template-mutation.js +598 -0
  177. package/dist/write/template-mutation.js.map +1 -0
  178. package/dist/write/undo.js +10 -20
  179. package/dist/write/undo.js.map +1 -1
  180. package/dist/write/update-fields.d.ts +151 -0
  181. package/dist/write/update-fields.js +427 -0
  182. package/dist/write/update-fields.js.map +1 -0
  183. package/dist/write/vectors/applescript.js +6 -10
  184. package/dist/write/vectors/applescript.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +1 -1
  186. package/dist/write/vectors/registry.js +10 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/shortcuts.js +2 -2
  189. package/dist/write/vectors/shortcuts.js.map +1 -1
  190. package/dist/write/vectors/simulator.js +15 -4
  191. package/dist/write/vectors/simulator.js.map +1 -1
  192. package/dist/write/vectors/types.d.ts +268 -1
  193. package/dist/write/vectors/ui-certification.js +63 -13
  194. package/dist/write/vectors/ui-certification.js.map +1 -1
  195. package/dist/write/vectors/ui-chord.d.ts +144 -0
  196. package/dist/write/vectors/ui-chord.js +428 -0
  197. package/dist/write/vectors/ui-chord.js.map +1 -0
  198. package/dist/write/vectors/ui-drag.d.ts +8 -4
  199. package/dist/write/vectors/ui-drag.js +2 -0
  200. package/dist/write/vectors/ui-drag.js.map +1 -1
  201. package/dist/write/vectors/ui-recipes.d.ts +70 -3
  202. package/dist/write/vectors/ui-recipes.js +482 -44
  203. package/dist/write/vectors/ui-recipes.js.map +1 -1
  204. package/dist/write/vectors/ui.d.ts +280 -10
  205. package/dist/write/vectors/ui.js +1068 -77
  206. package/dist/write/vectors/ui.js.map +1 -1
  207. package/dist/write/vectors/url-scheme.js +3 -0
  208. package/dist/write/vectors/url-scheme.js.map +1 -1
  209. package/dist/write/verify/delta.d.ts +62 -0
  210. package/dist/write/verify/delta.js +36 -2
  211. package/dist/write/verify/delta.js.map +1 -1
  212. package/dist/write/verify/poller.d.ts +4 -1
  213. package/dist/write/verify/poller.js +14 -0
  214. package/dist/write/verify/poller.js.map +1 -1
  215. package/package.json +2 -1
  216. package/schema/envelope.schema.json +6 -4
  217. package/scripts/build-helpers.sh +5 -0
  218. package/skills/things-cli/SKILL.md +5 -3
  219. package/skills/things-cli/references/contracts.md +1 -0
  220. package/skills/things-cli/references/data-model.md +3 -0
  221. package/skills/things-cli/references/errors.md +1 -1
  222. package/skills/things-cli/references/gui.md +1 -0
  223. package/skills/things-cli/references/repeating.md +6 -0
  224. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -0,0 +1,706 @@
1
+ /**
2
+ * Prompt-free capability detection (docs/design/permissions-doctrine.md,
3
+ * Articles I–III).
4
+ *
5
+ * Every entry point must know what it is allowed to do BEFORE it touches the
6
+ * live library, and it must find out without putting a macOS consent dialog on
7
+ * screen. This module is the single place that answers that question, so the
8
+ * read gate, the write gate, `things doctor`, and the `things setup` ceremony
9
+ * all quote the same verdict.
10
+ *
11
+ * Read capability is GROUND TRUTH per invocation, never a stored flag. Nothing
12
+ * here is memoized across calls and nothing is persisted except the one marker
13
+ * that records a live-instance fact (./session-grant.ts). The whole preflight
14
+ * is a single `open(2)` in the common case.
15
+ *
16
+ * The paths, in the order they are consulted:
17
+ *
18
+ * 1. an explicit database path — outside the doctrine entirely (Article VI);
19
+ * 2. the helpers — when the reader is serving this process's reads, the READ
20
+ * ITSELF is the check. The reader resolves its security-scoped bookmark on
21
+ * every verb and answers `not-granted` / `not-found` as typed errors, so
22
+ * there is nothing to pre-probe and no hello round-trip is added here. When
23
+ * the helpers are expected but cannot serve, the read FAILS LOUDLY — a
24
+ * silent fall-through to direct would move consent back onto the terminal,
25
+ * which is the exact churn the helpers exist to end;
26
+ * 3. Full Disk Access — one read-open of the user TCC database. FDA-class
27
+ * files never raise a dialog: the open either works or fails with a silent
28
+ * EPERM;
29
+ * 4. a witnessed session app-data grant — see ./session-grant.ts;
30
+ * 5. otherwise: refuse, naming both setup ceremonies.
31
+ *
32
+ * Step 2 asks nothing of this host's grants. Since helpers 1.3.0 the reader's
33
+ * socket and token live in `<state>/reader` — launchd owns the socket, install
34
+ * mints the token, and both are ordinary files this user owns — so "is the
35
+ * reader serving?" is answerable identically from every host app, with no
36
+ * consent class in play. (Before 1.3.0 they sat in the reader's App Sandbox
37
+ * container and that question was itself a cross-app container access.)
38
+ *
39
+ * The Things group container is NEVER opened as a probe (Article I corollary):
40
+ * the open is itself what raises the app-data consent, so "try it and see" is
41
+ * forbidden — except inside `things setup`, which provokes it deliberately.
42
+ *
43
+ * On the direct WRITE probe — why TCC introspection and not the AppleEvents
44
+ * API. The doctrine names `AEDeterminePermissionToAutomateTarget(askUserIfNeeded:
45
+ * false)` as the direct-path Automation probe, which is exactly what the deputy
46
+ * calls (deputy/src/tcc.swift). From a JXA/ObjC host that call is NOT reachable:
47
+ * JavaScriptCore's bridge cannot marshal an `AEDesc` struct. MEASURED 2026-08-24
48
+ * on macOS 24.6 — `AECreateDesc` fills an untyped `Ref()`, but passing that Ref
49
+ * to any function taking `^{AEDesc=…}` throws "Ref has incompatible type", and
50
+ * the one spelling the bridge accepts (`ref[0]`, a dereferenced copy) arrives
51
+ * zeroed: `AEGetDescDataSize` reports 0 bytes for a 36-byte bundle id, and every
52
+ * target then answers -50 paramErr. A probe that returns the same wrong answer
53
+ * for every input is worse than no probe, so it is not shipped. Reading the
54
+ * Automation row out of TCC.db is the other introspection the Article I
55
+ * corollary names, it is exactly as prompt-free, and it costs nothing extra:
56
+ * direct mode's floor is FDA anyway, so a process that can act at all can
57
+ * already read that file. Where the row cannot be read the verdict is an honest
58
+ * "unknown", which the write gate refuses on rather than resolving with a dialog.
59
+ */
60
+ import { execFileSync } from "node:child_process";
61
+ import { homedir } from "node:os";
62
+ import { join } from "node:path";
63
+ import { DatabaseSync } from "node:sqlite";
64
+ import { loadConfig } from "./config.js";
65
+ import { readContainerFileSync } from "./deputy/files.js";
66
+ import { deputyRouting, deputyRoutesDb, helpersExpected, settleDeputyAutomation, } from "./deputy/routing.js";
67
+ import { THINGS_BUNDLE_ID, wakeSystemEvents, wakeThings } from "./deputy/wake.js";
68
+ import { fdaGranted, hostApp, hostDisplayName, resetHostAccessForTests, tccDbPath, } from "./host-access.js";
69
+ import { sessionGrantValid } from "./session-grant.js";
70
+ export { fdaGranted, hostApp, hostDisplayName, tccDbPath, } from "./host-access.js";
71
+ /** The Things application's bundle identifier — the Automation grant's target. */
72
+ export { THINGS_BUNDLE_ID } from "./deputy/wake.js";
73
+ /** True when this verdict permits opening the live container. */
74
+ export function readAllowed(capability) {
75
+ return capability.mode !== "none" && capability.mode !== "helpers-unavailable";
76
+ }
77
+ /** True when this verdict permits driving the Things window. */
78
+ export function uiAllowed(capability) {
79
+ return capability.mode === "helpers" || capability.mode === "direct-escape";
80
+ }
81
+ /**
82
+ * True when a URL-scheme MUTATION may be dispatched.
83
+ *
84
+ * `unreadable` is permissive, and deliberately so — the asymmetry with the
85
+ * write gate is the point. There, an unknown standing is refused because
86
+ * resolving it means sending the Apple Event that IS the dialog. Here,
87
+ * dispatching costs nothing on a machine whose answer is already "enabled",
88
+ * which is every settled machine; only the two states we have positively READ
89
+ * as not-enabled are refused. What catches the unreadable case instead is the
90
+ * read-after-write verify plus its likely-cause hint (src/write/failure-hints.ts).
91
+ */
92
+ export function urlSchemeAllowed(capability) {
93
+ return capability.mode === "enabled" || capability.mode === "unreadable";
94
+ }
95
+ /** True when app automation may be dispatched. */
96
+ export function writeAllowed(capability) {
97
+ return (capability.mode === "deputy" ||
98
+ capability.mode === "direct-granted" ||
99
+ capability.mode === "direct-escape");
100
+ }
101
+ // ── The prompt-free probes ───────────────────────────────────────────────────
102
+ /**
103
+ * One Automation row from TCC.db: the `auth_value` macOS records for `client`
104
+ * driving `target`, or null when there is no row (never asked) or the file is
105
+ * unreadable (no FDA). Read-only, prompt-free, and defensive — the schema is
106
+ * Apple's private business, so ANY failure degrades to null rather than
107
+ * throwing into a caller that asked a yes/no question.
108
+ */
109
+ function automationAuthValueDefault(env, client, target) {
110
+ let db;
111
+ try {
112
+ db = new DatabaseSync(tccDbPath(env), { readOnly: true });
113
+ const row = db
114
+ .prepare("SELECT auth_value FROM access WHERE service = 'kTCCServiceAppleEvents' " +
115
+ "AND client = ? AND indirect_object_identifier = ? LIMIT 1")
116
+ .get(client, target);
117
+ const value = row?.auth_value;
118
+ return typeof value === "number" ? value : null;
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ finally {
124
+ try {
125
+ db?.close();
126
+ }
127
+ catch {
128
+ // A close failure cannot change the answer we already have.
129
+ }
130
+ }
131
+ }
132
+ // ── The lab escapes (not consumer surface) ───────────────────────────────────
133
+ /**
134
+ * Is one of the lab's documented escapes set? ({@link UI_DIRECT_ESCAPE_ENV},
135
+ * {@link WRITE_DIRECT_ESCAPE_ENV}.)
136
+ *
137
+ * Both are read in exactly one place each — the capability function for the
138
+ * vector they cover — and both spell "on" the same way, as the literal `1`.
139
+ * Anything else is off, deliberately: an escape that answered to `true`, `yes`
140
+ * or a stray empty string would be a config surface, and these are neither
141
+ * config nor consumer surface. They are documented in docs/lab/harness.md and
142
+ * exported by the lab's guest environment; nothing shipped mentions them.
143
+ */
144
+ function labEscapeSet(env, name) {
145
+ return (env[name] ?? "") === "1";
146
+ }
147
+ // ── Read capability (Article I + II) ─────────────────────────────────────────
148
+ /**
149
+ * Errnos that mean a plain "no" rather than something worth telling the user
150
+ * about. EPERM is the FDA denial proper; EACCES is its sandboxed cousin; ENOENT
151
+ * simply means this account has no TCC database yet. Anything else is an
152
+ * anomaly and gets named, so a genuinely odd failure is never silently folded
153
+ * into "you lack permission".
154
+ */
155
+ const ORDINARY_DENIALS = new Set(["EPERM", "EACCES", "ENOENT"]);
156
+ /**
157
+ * The ways a machine can earn read capability, phrased for a human.
158
+ *
159
+ * The ask-again line is deliberately CONDITIONAL, because of what this process
160
+ * can and cannot know. A container that will not open carries no cause: "this
161
+ * host app was never asked" and "this host app was asked and the human clicked
162
+ * Don't Allow" are the same observation from here, and the app-data class
163
+ * cannot be told apart without opening the container — which is the very act
164
+ * that raises the dialog (Article I corollary). What IS measured is the price
165
+ * of a refusal (APDP1, docs/lab/apdp1-grant-pinning.md): it stands for the
166
+ * whole life of that host-app instance, every later open failing instantly and
167
+ * silently, and macOS never re-asks inside it. So the line names the relaunch
168
+ * as the way to be asked again without asserting that anyone refused anything.
169
+ */
170
+ function readRemediation(hostName) {
171
+ return [
172
+ "run `things helpers setup` — reads then flow through a helper that holds its own durable grant",
173
+ "or run `things setup` — it asks for read access once, while you are at the machine",
174
+ `if that dialog was already refused, quit and reopen ${hostName} first — macOS does not ask a second time inside one run of an app`,
175
+ `or grant Full Disk Access to ${hostName} in System Settings ▸ Privacy & Security ▸ Full Disk Access`,
176
+ ];
177
+ }
178
+ /**
179
+ * Can this process read the live Things library, and on whose authority?
180
+ *
181
+ * Stateless: every call re-derives the verdict, because a grant can appear or
182
+ * vanish between one invocation and the next and a cached "yes" would be a
183
+ * stored onboarding flag by another name. The common case costs one `open(2)`.
184
+ */
185
+ export function readCapability(options = {}, deps = {}) {
186
+ const env = deps.env ?? process.env;
187
+ const host = hostApp(deps);
188
+ // Article VI — a path the caller already owns gets plain file semantics.
189
+ if (options.dbPath !== undefined || (env["THINGS_DB"] ?? "") !== "") {
190
+ return {
191
+ mode: "explicit-db",
192
+ detail: "an explicit database path was supplied — plain file semantics apply",
193
+ remediation: [],
194
+ host,
195
+ };
196
+ }
197
+ // The helpers path. Reads that ride the reader are not pre-checked here: the
198
+ // reader resolves its bookmark on every verb, so the read IS the check and a
199
+ // probe would only add latency and a second answer to disagree with.
200
+ const serving = (deps.helpersServing ?? (() => deputyRoutesDb(options, env)))();
201
+ if (serving) {
202
+ return {
203
+ mode: "helpers",
204
+ detail: "database reads are served by the sandboxed reader",
205
+ remediation: [],
206
+ host,
207
+ };
208
+ }
209
+ // No silent fall-through: a machine that asked for the helpers and cannot
210
+ // have them is refused, not quietly downgraded onto the terminal's own grants.
211
+ // Nothing gates this question any more — the rendezvous is ours, so whether
212
+ // the helpers are expected and whether they are serving are both answerable
213
+ // from every host app alike.
214
+ const expected = (deps.helpersExpected ?? (() => helpersExpected(env)))();
215
+ if (expected) {
216
+ const why = (deps.helpersReason ?? (() => null))();
217
+ return {
218
+ mode: "helpers-unavailable",
219
+ detail: `the helpers are enabled on this machine but are not serving reads${why !== null ? ` (${why})` : ""}`,
220
+ remediation: [
221
+ "run `things helpers setup` to finish onboarding them",
222
+ "or `things helpers status` to see which half is unhealthy",
223
+ "or `things --no-helpers …` to run this one invocation directly",
224
+ ],
225
+ host,
226
+ };
227
+ }
228
+ const fda = fdaGranted(deps);
229
+ if (fda.granted) {
230
+ return {
231
+ mode: "direct-fda",
232
+ detail: `Full Disk Access is held by ${hostDisplayName(deps)}${host.bundleId !== null ? ` (${host.bundleId})` : ""}`,
233
+ remediation: [],
234
+ host,
235
+ };
236
+ }
237
+ // The sub-FDA tier: a grant a ceremony witnessed, still live for this very
238
+ // app instance. Only consulted when FDA has already said no.
239
+ const session = sessionGrantValid(host.bundleId, deps);
240
+ if (session.valid) {
241
+ const name = hostDisplayName(deps);
242
+ return {
243
+ mode: "session-grant",
244
+ // MEASURED (APDP1): the grant belongs to the host app INSTANCE, so it
245
+ // covers every process under it — this command, other tabs and windows,
246
+ // anything they spawn — and it ends when that app quits. The copy states
247
+ // both halves, because the reach is the part that is worth knowing and
248
+ // the expiry is the part that must never read as durable.
249
+ detail: `${name} holds access to the Things data folder — every command running under ${name}, in any tab or window, reads it without a dialog until ${name} quits`,
250
+ remediation: [],
251
+ host,
252
+ };
253
+ }
254
+ return {
255
+ mode: "none",
256
+ detail: `${hostDisplayName(deps)} cannot open the Things data folder — ${session.reason}` +
257
+ (fda.code !== null && !ORDINARY_DENIALS.has(fda.code)
258
+ ? ` (the access check ended in ${fda.code})`
259
+ : ""),
260
+ remediation: readRemediation(hostDisplayName(deps)),
261
+ host,
262
+ };
263
+ }
264
+ /** Thrown when a read is refused for want of capability (Article II). */
265
+ export class ReadCapabilityError extends Error {
266
+ remediation;
267
+ capability;
268
+ constructor(capability) {
269
+ super(`the Things database cannot be read: ${capability.detail}. ` +
270
+ `${capability.remediation.join("; ")}.`);
271
+ this.name = "ReadCapabilityError";
272
+ this.remediation = capability.remediation;
273
+ this.capability = capability;
274
+ }
275
+ }
276
+ // ── Write capability (Article I + II) ────────────────────────────────────────
277
+ /**
278
+ * macOS `auth_value` for an Automation record. 0 is a refusal; 2 (allowed) and
279
+ * 3 both mean the event will be delivered.
280
+ */
281
+ function classifyAuthValue(value) {
282
+ if (value === null)
283
+ return "unknown";
284
+ if (value === 0)
285
+ return "denied";
286
+ if (value >= 2)
287
+ return "granted";
288
+ return "unknown";
289
+ }
290
+ /**
291
+ * The LAB's escape hatch for the AppleScript vector — the write-side twin of
292
+ * {@link UI_DIRECT_ESCAPE_ENV}, and just as deliberately not consumer surface.
293
+ *
294
+ * A guest shell in a golden clone descends from sshd, not from an application
295
+ * bundle, so `hostApp()` finds no `__CFBundleIdentifier` and macOS has no
296
+ * identity to have recorded an Automation grant against. The verdict is
297
+ * therefore `direct-unknown` in every clone, which blocks every AppleScript-
298
+ * vector verb and every composite carrying an AppleScript leg. What the clone
299
+ * actually has is an in-guest Automation grant on the runner's own processes
300
+ * (the same AXVM1 layer the ui escape leans on), so the block is an artefact of
301
+ * UNKNOWABILITY, not of a missing grant. Setting this to `1` says so.
302
+ *
303
+ * Bounded, and the bound is the point: it is consulted ONLY on the
304
+ * bundle-id-less path. A host that has an identity is answered from its TCC row
305
+ * as it always was, so the escape can never mask a recorded refusal
306
+ * (`direct-denied`) or manufacture a grant for a real user's terminal.
307
+ * Documented in docs/lab/harness.md and exported by the lab's guest
308
+ * environment; nothing consumer-facing mentions it.
309
+ */
310
+ export const WRITE_DIRECT_ESCAPE_ENV = "THINGS_API_WRITE_DIRECT";
311
+ /**
312
+ * May this process drive Things over Apple Events, and on whose authority?
313
+ *
314
+ * The deputy wins when it is onboarded (its own handshake reports the grant it
315
+ * holds). Otherwise the host app's own Automation record is read out of TCC —
316
+ * granted, denied, or, when there is no record at all, `direct-unknown`. That
317
+ * last state is deliberately NOT resolved here: resolving it means sending a
318
+ * real Apple Event, which is what raises the dialog, and Article I reserves
319
+ * that for `things setup`.
320
+ *
321
+ * LIVENESS BEFORE AUTHORIZATION (#617). The deputy's `not-running` is the
322
+ * ask-false determination having no answer for a CLOSED Things — a fact about
323
+ * the app's process, not about the grant. Two rules follow, and both matter:
324
+ *
325
+ * - while the deputy is standing, that value NEVER falls through to the direct
326
+ * host branch. A silent direct engagement would put consent back on the
327
+ * terminal (the routing doctrine's no-fallback rule), and on a machine with
328
+ * no host record it would refuse a fully onboarded user with "run
329
+ * `things setup`" — the #610 false-onboarding loop, one vector over;
330
+ * - a `dispatch` caller resolves it by STARTING Things (a background
331
+ * LaunchServices dispatch, never an Apple Event) and re-reading the
332
+ * standing. Only what comes back is an authorization fact.
333
+ */
334
+ export function writeCapability(options = {}, deps = {}) {
335
+ const env = deps.env ?? process.env;
336
+ const host = hostApp(deps);
337
+ const handshake = (deps.deputyAutomation ?? (() => deputyRouting(env).hello?.automation?.things))();
338
+ let deputyThings = handshake;
339
+ if (handshake === "not-running") {
340
+ let wake = null;
341
+ if (options.purpose === "dispatch") {
342
+ wake = (deps.wakeThings ?? (() => wakeThings(env)))();
343
+ deputyThings = wake.standing;
344
+ }
345
+ if (deputyThings === undefined || deputyThings === "not-running") {
346
+ return {
347
+ mode: "deputy-target-dormant",
348
+ detail: wake === null
349
+ ? "Things is not running, so app control for it cannot be read — macOS answers for a " +
350
+ "running app only, and whatever the helpers hold is unreadable while it is down"
351
+ : `Things is not running and ${wake.detail} — app control for it cannot be read while it is down`,
352
+ remediation: [
353
+ "open Things, then rerun this command",
354
+ `or start it in the background with \`open -g -b ${THINGS_BUNDLE_ID}\``,
355
+ ],
356
+ host,
357
+ };
358
+ }
359
+ // A real standing at last: hand it to the routing layer, whose own
360
+ // onboarding gate deferred on the same non-answer (./deputy/routing.ts).
361
+ (deps.settleDeputyAutomation ?? settleDeputyAutomation)(deputyThings);
362
+ }
363
+ if (deputyThings === "granted") {
364
+ return {
365
+ mode: "deputy",
366
+ detail: "the deputy is onboarded and holds app control for Things",
367
+ remediation: [],
368
+ host,
369
+ };
370
+ }
371
+ if (host.bundleId === null) {
372
+ if (labEscapeSet(env, WRITE_DIRECT_ESCAPE_ENV)) {
373
+ return {
374
+ mode: "direct-escape",
375
+ detail: `${WRITE_DIRECT_ESCAPE_ENV}=1 — Apple Events are sent directly under this process (lab escape)`,
376
+ remediation: [],
377
+ host,
378
+ };
379
+ }
380
+ return {
381
+ mode: "direct-unknown",
382
+ detail: "this process does not descend from an application bundle, so macOS has no identity " +
383
+ "to record app control against",
384
+ remediation: [
385
+ "run `things helpers setup` — app control then attaches to a helper that always has an identity",
386
+ ],
387
+ host,
388
+ };
389
+ }
390
+ const authValue = (deps.automationAuthValue ??
391
+ ((client, target) => automationAuthValueDefault(env, client, target)))(host.bundleId, THINGS_BUNDLE_ID);
392
+ const hostName = hostDisplayName(deps);
393
+ switch (classifyAuthValue(authValue)) {
394
+ case "granted":
395
+ return {
396
+ mode: "direct-granted",
397
+ detail: `${hostName} (${host.bundleId}) holds app control for Things`,
398
+ remediation: [],
399
+ host,
400
+ };
401
+ case "denied":
402
+ return {
403
+ mode: "direct-denied",
404
+ detail: `macOS records a refusal of app control for ${hostName} (${host.bundleId}) — it will not ask again`,
405
+ remediation: [
406
+ `turn on Things3 for ${hostName} under System Settings ▸ Privacy & Security ▸ Automation`,
407
+ `or re-arm the request with \`tccutil reset AppleEvents ${host.bundleId}\`, then run \`things setup\``,
408
+ ],
409
+ host,
410
+ };
411
+ default:
412
+ return {
413
+ mode: "direct-unknown",
414
+ detail: `macOS has no app-control record for ${hostName} (${host.bundleId}) yet`,
415
+ remediation: [
416
+ "run `things setup` — it asks for app control once, while you are at the machine",
417
+ "or run `things helpers setup` to attach the grant to a helper instead",
418
+ ],
419
+ host,
420
+ };
421
+ }
422
+ }
423
+ /** Thrown when app automation is refused for want of capability (Article II). */
424
+ export class WriteCapabilityError extends Error {
425
+ remediation;
426
+ capability;
427
+ constructor(capability) {
428
+ super(`Things cannot be driven: ${capability.detail}. ${capability.remediation.join("; ")}.`);
429
+ this.name = "WriteCapabilityError";
430
+ this.remediation = capability.remediation;
431
+ this.capability = capability;
432
+ }
433
+ }
434
+ // ── UI capability (Article IV) ───────────────────────────────────────────────
435
+ /**
436
+ * The LAB's escape hatch for the ui vector, and deliberately not consumer
437
+ * surface (its write-side twin is {@link WRITE_DIRECT_ESCAPE_ENV}). The VM lab and
438
+ * the guest e2e bundle drive the UI vector DIRECT — the in-guest Accessibility
439
+ * grant is held by the runner's own processes (the AXVM1 layer), there is no
440
+ * helper bundle in a disposable clone, and there is nobody to answer a dialog
441
+ * either. Setting this to `1` restores direct UI-vector availability for that
442
+ * one situation. It is documented in docs/lab/harness.md and exported by the
443
+ * lab's guest environment; nothing consumer-facing mentions it, and it does not
444
+ * bypass `ui.enabled` — a lab clone still sets that key explicitly.
445
+ */
446
+ export const UI_DIRECT_ESCAPE_ENV = "THINGS_API_UI_DIRECT";
447
+ /** The command that gathers the GUI-driving tier, named in every UI refusal. */
448
+ const GUI_SETUP_COMMAND = "run `things helpers setup --gui` to grant GUI-driving to the helpers";
449
+ function deputyGuiStandingDefault(env) {
450
+ const hello = deputyRouting(env).hello;
451
+ if (hello === undefined || hello === null)
452
+ return null;
453
+ return { axTrusted: hello.axTrusted, systemEvents: hello.automation?.systemEvents };
454
+ }
455
+ /**
456
+ * May this process drive the Things WINDOW, and on whose authority?
457
+ *
458
+ * Article IV admits exactly one provenance: the helper pair. Accessibility on a
459
+ * general-purpose host app (a terminal, an agent harness, an MCP host) has a
460
+ * blast radius far beyond Things, churns with every host update, and has no
461
+ * sane story at all over ssh — so direct AX is unsupported, and a refusal here
462
+ * names the config knob and `things helpers setup --gui` rather than raising an
463
+ * Accessibility prompt against whatever happens to be running us.
464
+ *
465
+ * Every answer is prompt-free: the config key is a file read, and the deputy's
466
+ * `hello` carries `AXIsProcessTrusted()` plus its own `AEDeterminePermission`
467
+ * verdict for System Events. The one state that needs more than a read is a
468
+ * DORMANT System Events, which macOS reaps whenever it has been idle: the
469
+ * target is started in the background — never by sending it an event — and the
470
+ * determination re-read, so `not-running` resolves to the truth instead of
471
+ * masquerading as a missing grant (./deputy/wake.ts).
472
+ */
473
+ export function uiCapability(deps = {}) {
474
+ const env = deps.env ?? process.env;
475
+ const host = hostApp(deps);
476
+ const enabled = (deps.uiEnabled ?? (() => loadConfig(env).ui.enabled))();
477
+ if (!enabled) {
478
+ return {
479
+ mode: "config-disabled",
480
+ detail: "GUI-driving is switched off on this machine (`ui-enabled` is false)",
481
+ remediation: [
482
+ "run `things config set ui-enabled true` to opt in",
483
+ `then ${GUI_SETUP_COMMAND}`,
484
+ ],
485
+ host,
486
+ };
487
+ }
488
+ if (labEscapeSet(env, UI_DIRECT_ESCAPE_ENV)) {
489
+ return {
490
+ mode: "direct-escape",
491
+ detail: `${UI_DIRECT_ESCAPE_ENV}=1 — GUI-driving runs directly under this process (lab escape)`,
492
+ remediation: [],
493
+ host,
494
+ };
495
+ }
496
+ const standing = (deps.deputyGuiStanding ?? (() => deputyGuiStandingDefault(env)))();
497
+ if (standing === null) {
498
+ return {
499
+ mode: "helpers-missing",
500
+ detail: "GUI-driving is granted only to the helpers, and no helper is answering on this machine",
501
+ remediation: [GUI_SETUP_COMMAND, "or `things helpers status` to see which half is unhealthy"],
502
+ host,
503
+ };
504
+ }
505
+ // LIVENESS BEFORE AUTHORIZATION (#610). System Events is an on-demand agent
506
+ // macOS reaps when idle, and the ask-false determination cannot answer for a
507
+ // target that is down — so the deputy's `not-running` describes the PROCESS,
508
+ // not the grant, and treating it as a missing grant sends a fully onboarded
509
+ // machine back through onboarding. Start it (a background launch raises no
510
+ // dialog; waking it with an Apple event would), then re-read the
511
+ // determination. Only after that is the standing an authorization fact.
512
+ let systemEvents = standing.systemEvents;
513
+ let wake = null;
514
+ if (systemEvents === "not-running") {
515
+ wake = (deps.wakeSystemEvents ?? (() => wakeSystemEvents(env)))();
516
+ systemEvents = wake.standing;
517
+ }
518
+ if (wake !== null && (systemEvents === undefined || systemEvents === "not-running")) {
519
+ return {
520
+ mode: "target-unreachable",
521
+ detail: `System Events is not running and ${wake.detail} — the Things window is driven through it`,
522
+ remediation: [
523
+ 'start it with `open -g -a "System Events"`, then rerun this command',
524
+ "or log out and back in — System Events is a macOS component of your login session",
525
+ ],
526
+ host,
527
+ };
528
+ }
529
+ const missing = [];
530
+ if (standing.axTrusted !== true) {
531
+ missing.push(standing.axTrusted === undefined
532
+ ? "Accessibility (these helpers predate the permission handshake — rebuild)"
533
+ : "Accessibility");
534
+ }
535
+ if (systemEvents !== "granted") {
536
+ missing.push(`automation → System Events (${systemEvents ?? "unknown"})`);
537
+ }
538
+ if (missing.length === 0) {
539
+ return {
540
+ mode: "helpers",
541
+ detail: "the helpers hold Accessibility and app control for System Events",
542
+ remediation: [],
543
+ host,
544
+ };
545
+ }
546
+ return {
547
+ mode: "tier-incomplete",
548
+ detail: `the helpers are onboarded but the GUI-driving tier is incomplete — missing ${missing.join("; ")}`,
549
+ remediation: [GUI_SETUP_COMMAND],
550
+ host,
551
+ };
552
+ }
553
+ // ── URL-scheme capability (Things' own in-app authorization) ─────────────────
554
+ /**
555
+ * Where the app records the answer. MEASURED (URLEN1, Things 3.23): this is the
556
+ * ONLY home — the un-TCC'd user domain `~/Library/Preferences/
557
+ * com.culturedcode.ThingsMac.plist` carries window frames and Sparkle keys and
558
+ * nothing else, under any spelling of the name, so there is no consent-free
559
+ * copy to prefer. The authoritative one lives inside the group container.
560
+ */
561
+ const THINGS_PREFS_PLIST = join("Library/Group Containers/JLMPQHK86H.com.culturedcode.ThingsMac", "Library/Preferences/JLMPQHK86H.com.culturedcode.ThingsMac.plist");
562
+ /**
563
+ * Read standings that ALREADY cover the Things group container, so opening a
564
+ * file inside it adds no consent class that is not already settled.
565
+ *
566
+ * `explicit-db` is deliberately absent: a caller-supplied database path says
567
+ * nothing about the container, and opening it to find out is the "try it and
568
+ * see" the Article I corollary forbids. Such a caller gets `unreadable`, which
569
+ * is permissive — exactly the right direction for a path outside the doctrine.
570
+ */
571
+ const CONTAINER_REACHABLE = new Set([
572
+ "helpers",
573
+ "direct-fda",
574
+ "session-grant",
575
+ ]);
576
+ function extractUriSchemeEnabledDefault(plistBytes) {
577
+ return execFileSync("plutil", ["-extract", "uriSchemeEnabled", "raw", "-o", "-", "--", "-"], {
578
+ input: plistBytes,
579
+ encoding: "utf8",
580
+ timeout: 5000,
581
+ });
582
+ }
583
+ /** The one line every not-enabled verdict ends with. */
584
+ const URL_SETTINGS_PATH = "Things ▸ Settings ▸ General ▸ Enable Things URLs";
585
+ /**
586
+ * Has Things been authorized to act on `things:///` commands, and how do we know?
587
+ *
588
+ * Stateless per invocation, like every other verdict here: the setting is the
589
+ * user's to flip at any moment, and a cached "yes" would be a stored onboarding
590
+ * flag by another name. The common case costs one file read plus one `plutil`.
591
+ *
592
+ * WHY THIS IS GATED ON THE READ STANDING. The plist lives inside the Things
593
+ * group container, which is the same `kTCCServiceSystemPolicyAppData` class as
594
+ * the database — so the open is itself what would raise the app-data modal on a
595
+ * machine that holds no standing. This function therefore never opens it
596
+ * speculatively: it asks {@link readCapability} first and reports `unreadable`
597
+ * unless the container is already reachable (helpers, FDA, or a live session
598
+ * grant). Where the helpers are serving, the read rides the reader's own
599
+ * security-scoped bookmark over the container, so the prefs plist is inside the
600
+ * granted subtree and no host grant is involved at all.
601
+ *
602
+ * MEASURED, all three states (URLEN1, golden-v4 / Things 3.23):
603
+ *
604
+ * - `1` — URL mutations execute.
605
+ * - `0` — every mutating verb (`add`, token-bearing `update`, the `json`
606
+ * batch) is dropped in TOTAL SILENCE: zero row delta, no dialog, no window
607
+ * of any kind, and nothing to wait for. Navigation URLs (`things:///show`)
608
+ * still work, so this gate covers mutations only.
609
+ * - absent — nobody has answered the app's own first-use "Things URL Scheme"
610
+ * dialog (Cancel / Enable). The dispatched request PARKS behind that dialog
611
+ * rather than being dropped, which is what #611 saw: with nobody at the
612
+ * machine, the verify window expired and the write reported a silent no-op.
613
+ */
614
+ export function urlSchemeCapability(deps = {}) {
615
+ const host = hostApp(deps);
616
+ const standing = (deps.readStanding ?? (() => readCapability({}, deps)))();
617
+ if (!CONTAINER_REACHABLE.has(standing.mode)) {
618
+ return {
619
+ mode: "unreadable",
620
+ detail: "the app's preferences are inside the Things data folder, which this process has no " +
621
+ `standing to open (${standing.mode}) — whether ${URL_SETTINGS_PATH} is on is unknown`,
622
+ remediation: [],
623
+ host,
624
+ };
625
+ }
626
+ let bytes;
627
+ try {
628
+ bytes = (deps.readPrefsPlist ?? (() => readContainerFileSync(join(homedir(), THINGS_PREFS_PLIST))))();
629
+ }
630
+ catch {
631
+ return {
632
+ mode: "unreadable",
633
+ detail: `the app's preferences file could not be read — whether ${URL_SETTINGS_PATH} is on is unknown`,
634
+ remediation: [],
635
+ host,
636
+ };
637
+ }
638
+ let raw;
639
+ try {
640
+ raw = (deps.extractUriSchemeEnabled ?? extractUriSchemeEnabledDefault)(bytes).trim();
641
+ }
642
+ catch {
643
+ // The file was readable and carries no such key. An unparseable file lands
644
+ // here too, and that is the safe direction: the refusal names a setting the
645
+ // human can flip, and flipping it rewrites the file and clears the verdict.
646
+ return {
647
+ mode: "never-asked",
648
+ detail: "nobody has answered Things' own 'Things URL Scheme' dialog on this machine — the app " +
649
+ "holds the first URL command behind it, and a command dispatched now waits there " +
650
+ "instead of running",
651
+ remediation: [
652
+ `turn on ${URL_SETTINGS_PATH}, then retry`,
653
+ "or send one `things:///` command while you are at the machine and click Enable",
654
+ ],
655
+ host,
656
+ };
657
+ }
658
+ if (raw === "1" || raw === "true") {
659
+ return { mode: "enabled", detail: `${URL_SETTINGS_PATH} is on`, remediation: [], host };
660
+ }
661
+ if (raw === "0" || raw === "false") {
662
+ return {
663
+ mode: "disabled",
664
+ detail: `${URL_SETTINGS_PATH} is off — the app puts URL commands in an alert on its own ` +
665
+ "window and holds them there instead of running them",
666
+ remediation: [`turn on ${URL_SETTINGS_PATH}, then retry`],
667
+ host,
668
+ };
669
+ }
670
+ // A shape we have never seen. Reporting it verbatim beats guessing which way
671
+ // the app would read it, and `unreadable` lets the write proceed and be judged
672
+ // by the verify rather than refused on a value nobody has measured.
673
+ return {
674
+ mode: "unreadable",
675
+ detail: `the app records an unrecognized value for ${URL_SETTINGS_PATH} (${raw})`,
676
+ remediation: [],
677
+ host,
678
+ };
679
+ }
680
+ /** Thrown when GUI-driving is refused for want of capability (Article IV). */
681
+ export class UiCapabilityError extends Error {
682
+ remediation;
683
+ capability;
684
+ constructor(capability) {
685
+ super(`the Things window cannot be driven: ${capability.detail}. ${capability.remediation.join("; ")}.`);
686
+ this.name = "UiCapabilityError";
687
+ this.remediation = capability.remediation;
688
+ this.capability = capability;
689
+ }
690
+ }
691
+ /** Thrown when a URL-scheme mutation is refused for want of the app's own authorization. */
692
+ export class UrlSchemeCapabilityError extends Error {
693
+ remediation;
694
+ capability;
695
+ constructor(capability) {
696
+ super(`Things will not act on URL commands: ${capability.detail}. ${capability.remediation.join("; ")}.`);
697
+ this.name = "UrlSchemeCapabilityError";
698
+ this.remediation = capability.remediation;
699
+ this.capability = capability;
700
+ }
701
+ }
702
+ /** Test seam: forget the one memo this module keeps (the host's display name). */
703
+ export function resetCapabilityForTests() {
704
+ resetHostAccessForTests();
705
+ }
706
+ //# sourceMappingURL=capability.js.map