toolroll 0.9.62 → 0.9.65

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 (345) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +66 -8
  3. package/dist/acp-agents.d.ts +43 -0
  4. package/dist/acp-agents.js +62 -0
  5. package/dist/acp-attest.d.ts +44 -0
  6. package/dist/acp-attest.js +269 -0
  7. package/dist/acp-check.d.ts +28 -0
  8. package/dist/acp-check.js +410 -0
  9. package/dist/acp-client.d.ts +163 -0
  10. package/dist/acp-client.js +738 -0
  11. package/dist/acp-inspect.d.ts +23 -0
  12. package/dist/acp-inspect.js +109 -0
  13. package/dist/agent-board.d.ts +19 -6
  14. package/dist/agent-board.js +30 -9
  15. package/dist/agent-fence.d.ts +203 -10
  16. package/dist/agent-fence.js +623 -41
  17. package/dist/agent-pulse.js +9 -2
  18. package/dist/agent-state-words.d.ts +19 -0
  19. package/dist/agent-state-words.js +20 -0
  20. package/dist/agent-state.d.ts +65 -0
  21. package/dist/agent-state.js +229 -0
  22. package/dist/agentconfig.js +36 -13
  23. package/dist/agents-board.d.ts +34 -2
  24. package/dist/agents-board.js +110 -1
  25. package/dist/approval-rules-ui.js +2 -2
  26. package/dist/assignment-delivery.d.ts +14 -0
  27. package/dist/assignment-delivery.js +13 -0
  28. package/dist/assignment.js +13 -0
  29. package/dist/attest.js +7 -0
  30. package/dist/backup-ui.js +2 -2
  31. package/dist/browser/THIRD_PARTY_NOTICES.txt +48 -0
  32. package/dist/browser/terminal.css +37 -0
  33. package/dist/browser/terminal.js +117 -0
  34. package/dist/browser/workspace.css +2 -2
  35. package/dist/browser/workspace.js +42 -38
  36. package/dist/browser-shell.js +6 -3
  37. package/dist/browser-workspace.d.ts +263 -1
  38. package/dist/browser-workspace.js +60 -0
  39. package/dist/builder.d.ts +6 -1
  40. package/dist/builder.js +34 -13
  41. package/dist/chat-actions.d.ts +13 -1
  42. package/dist/chat-actions.js +199 -17
  43. package/dist/chat-ask.d.ts +4 -0
  44. package/dist/chat-ask.js +14 -0
  45. package/dist/chat-channel.js +20 -0
  46. package/dist/chat-continuity.js +5 -4
  47. package/dist/chat-decide.d.ts +0 -14
  48. package/dist/chat-decide.js +4 -1
  49. package/dist/chat-delivery.d.ts +9 -0
  50. package/dist/chat-delivery.js +45 -6
  51. package/dist/chat-messages.d.ts +8 -0
  52. package/dist/chat-messages.js +36 -4
  53. package/dist/check-levels-ui.js +2 -2
  54. package/dist/check-levels.js +3 -0
  55. package/dist/checkout-proof.d.ts +28 -0
  56. package/dist/checkout-proof.js +62 -0
  57. package/dist/claim.d.ts +12 -0
  58. package/dist/claim.js +20 -1
  59. package/dist/cli-help.d.ts +1 -1
  60. package/dist/cli-help.js +5 -1
  61. package/dist/cli.d.ts +3 -0
  62. package/dist/cli.js +24 -2
  63. package/dist/coding-ui.js +6 -4
  64. package/dist/console-instance.d.ts +124 -0
  65. package/dist/console-instance.js +337 -0
  66. package/dist/contracts/chat-actions.d.ts +16 -0
  67. package/dist/contracts/chat-actions.js +4 -0
  68. package/dist/contracts/chat-content.d.ts +19 -0
  69. package/dist/contracts/chat-content.js +6 -0
  70. package/dist/contracts/cli.d.ts +8 -0
  71. package/dist/contracts/cli.js +3 -1
  72. package/dist/contracts/console-api.d.ts +30 -4
  73. package/dist/contracts/console-api.js +13 -4
  74. package/dist/contracts/flow-card.d.ts +4 -0
  75. package/dist/contracts/flow-card.js +3 -0
  76. package/dist/contracts/flow.d.ts +46 -0
  77. package/dist/contracts/flow.js +11 -2
  78. package/dist/contracts/integration-metadata.d.ts +2 -1
  79. package/dist/contracts/integration-metadata.js +2 -1
  80. package/dist/contracts/lead-tools.d.ts +92 -0
  81. package/dist/contracts/lead-tools.js +13 -0
  82. package/dist/contracts/provider.d.ts +11 -1
  83. package/dist/contracts/provider.js +1 -1
  84. package/dist/contracts/route.d.ts +76 -1
  85. package/dist/contracts/route.js +1 -1
  86. package/dist/contracts/store-json.d.ts +2 -0
  87. package/dist/control-setup.d.ts +1 -1
  88. package/dist/control-ui.js +2 -2
  89. package/dist/converse.d.ts +16 -0
  90. package/dist/converse.js +32 -4
  91. package/dist/demo-chat.js +4 -4
  92. package/dist/demo.js +2 -2
  93. package/dist/desktop-update-gate.js +6 -0
  94. package/dist/discord-chat.js +4 -1
  95. package/dist/dispatch.js +7 -4
  96. package/dist/doctor.d.ts +53 -0
  97. package/dist/doctor.js +427 -0
  98. package/dist/egress-bridge.d.ts +17 -0
  99. package/dist/egress-bridge.js +111 -0
  100. package/dist/egress.d.ts +119 -0
  101. package/dist/egress.js +423 -0
  102. package/dist/evidence-pack.d.ts +1 -1
  103. package/dist/evidence-pack.js +2 -2
  104. package/dist/exec.d.ts +81 -0
  105. package/dist/exec.js +368 -9
  106. package/dist/export-ui.js +2 -2
  107. package/dist/flow-code.d.ts +2 -2
  108. package/dist/flow-code.js +4 -15
  109. package/dist/flow-engine.js +35 -11
  110. package/dist/flow-gallery-ui.js +2 -2
  111. package/dist/flow-gallery.js +27 -8
  112. package/dist/flow-share.js +16 -5
  113. package/dist/flow-triggers.d.ts +84 -11
  114. package/dist/flow-triggers.js +554 -87
  115. package/dist/flow-unattended-words.d.ts +28 -0
  116. package/dist/flow-unattended-words.js +50 -0
  117. package/dist/flow-unattended.d.ts +227 -0
  118. package/dist/flow-unattended.js +776 -0
  119. package/dist/flows-cli.d.ts +28 -1
  120. package/dist/flows-cli.js +63 -12
  121. package/dist/flows-ui.d.ts +2 -3
  122. package/dist/flows-ui.js +56 -5
  123. package/dist/flows.d.ts +4 -0
  124. package/dist/flows.js +9 -1
  125. package/dist/google-mail.d.ts +1 -0
  126. package/dist/google-mail.js +61 -18
  127. package/dist/html.d.ts +9 -0
  128. package/dist/html.js +11 -0
  129. package/dist/integrations-ui.d.ts +2 -1
  130. package/dist/integrations-ui.js +6 -3
  131. package/dist/integrations.d.ts +17 -3
  132. package/dist/integrations.js +38 -18
  133. package/dist/invoke.d.ts +17 -0
  134. package/dist/invoke.js +510 -182
  135. package/dist/keys.d.ts +18 -14
  136. package/dist/keys.js +5 -0
  137. package/dist/landed-cleanup.d.ts +3 -2
  138. package/dist/landed-cleanup.js +12 -5
  139. package/dist/lead-capabilities.js +3 -2
  140. package/dist/lead-contract.js +2 -0
  141. package/dist/lead-doors.d.ts +2 -0
  142. package/dist/lead-doors.js +7 -0
  143. package/dist/lead-follow.d.ts +2 -0
  144. package/dist/lead-follow.js +58 -2
  145. package/dist/lead-progress.js +1 -0
  146. package/dist/lead-queue-runtime.d.ts +46 -0
  147. package/dist/lead-queue-runtime.js +191 -0
  148. package/dist/lead-queue.d.ts +104 -0
  149. package/dist/lead-queue.js +187 -0
  150. package/dist/lead-sweep.d.ts +67 -0
  151. package/dist/lead-sweep.js +99 -0
  152. package/dist/lead-tools.d.ts +22 -0
  153. package/dist/lead-tools.js +163 -4
  154. package/dist/lead.d.ts +13 -5
  155. package/dist/lead.js +89 -29
  156. package/dist/limits-ui.d.ts +8 -1
  157. package/dist/limits-ui.js +26 -0
  158. package/dist/live-bus.d.ts +8 -0
  159. package/dist/live-bus.js +40 -4
  160. package/dist/live-detail.d.ts +27 -0
  161. package/dist/live-detail.js +57 -0
  162. package/dist/live-notes.d.ts +20 -0
  163. package/dist/live-notes.js +47 -0
  164. package/dist/live-records.d.ts +140 -0
  165. package/dist/live-records.js +935 -0
  166. package/dist/live-terminal.d.ts +258 -0
  167. package/dist/live-terminal.js +649 -0
  168. package/dist/live.d.ts +38 -27
  169. package/dist/live.js +142 -124
  170. package/dist/mcp-connect.d.ts +8 -7
  171. package/dist/mcp-connect.js +49 -51
  172. package/dist/mcp-oauth.d.ts +6 -3
  173. package/dist/mcp-oauth.js +8 -4
  174. package/dist/memory-pass.js +2 -2
  175. package/dist/mobile-viewport.d.ts +5 -1
  176. package/dist/mobile-viewport.js +18 -2
  177. package/dist/model-catalog.js +4 -0
  178. package/dist/monitoring-ui.js +2 -2
  179. package/dist/needs-you.d.ts +1 -1
  180. package/dist/needs-you.js +11 -4
  181. package/dist/oauth-run-token.d.ts +51 -0
  182. package/dist/oauth-run-token.js +127 -0
  183. package/dist/operate.d.ts +2 -0
  184. package/dist/operate.js +282 -18
  185. package/dist/passwordless.d.ts +23 -0
  186. package/dist/passwordless.js +26 -0
  187. package/dist/peek-cli.d.ts +12 -4
  188. package/dist/peek-cli.js +19 -11
  189. package/dist/phase-routing.js +10 -3
  190. package/dist/plan-auto.d.ts +11 -0
  191. package/dist/plan-auto.js +62 -11
  192. package/dist/planner-source.d.ts +3 -0
  193. package/dist/planner-source.js +14 -0
  194. package/dist/planner.d.ts +2 -1
  195. package/dist/planner.js +7 -1
  196. package/dist/policy-ui.js +2 -2
  197. package/dist/policy.d.ts +1 -20
  198. package/dist/policy.js +29 -2
  199. package/dist/process-recovery-eligibility.d.ts +1 -1
  200. package/dist/project-access.d.ts +3 -0
  201. package/dist/project-access.js +3 -0
  202. package/dist/project-delete-ui.d.ts +12 -0
  203. package/dist/project-delete-ui.js +41 -9
  204. package/dist/project-delete.d.ts +3 -1
  205. package/dist/project-delete.js +12 -6
  206. package/dist/project-file.d.ts +3 -0
  207. package/dist/project-file.js +16 -0
  208. package/dist/project-memory.d.ts +3 -1
  209. package/dist/project-memory.js +57 -25
  210. package/dist/project-tools.d.ts +103 -27
  211. package/dist/project-tools.js +399 -100
  212. package/dist/provider-auth.d.ts +4 -3
  213. package/dist/provider-auth.js +5 -4
  214. package/dist/provider-connection.d.ts +2 -1
  215. package/dist/provider-connection.js +5 -0
  216. package/dist/provider-home.d.ts +22 -0
  217. package/dist/provider-home.js +99 -0
  218. package/dist/provider.d.ts +42 -5
  219. package/dist/provider.js +97 -19
  220. package/dist/pull-request-ui.js +2 -2
  221. package/dist/push.js +1 -1
  222. package/dist/quick-start.d.ts +24 -0
  223. package/dist/quick-start.js +127 -0
  224. package/dist/release.d.ts +3 -0
  225. package/dist/release.js +14 -8
  226. package/dist/remote-main.d.ts +4 -0
  227. package/dist/remote-main.js +3 -3
  228. package/dist/request-budget-ui.js +2 -2
  229. package/dist/result-review.d.ts +1 -1
  230. package/dist/result-review.js +9 -3
  231. package/dist/retention-ui.js +2 -2
  232. package/dist/retention.js +10 -1
  233. package/dist/run-efficiency.d.ts +73 -0
  234. package/dist/run-efficiency.js +228 -0
  235. package/dist/runner.d.ts +5 -1
  236. package/dist/runner.js +22 -1
  237. package/dist/scope.d.ts +171 -6
  238. package/dist/scope.js +251 -31
  239. package/dist/scout-net.d.ts +4 -1
  240. package/dist/scout-net.js +37 -12
  241. package/dist/scout.d.ts +20 -6
  242. package/dist/scout.js +27 -3
  243. package/dist/serve.js +258 -43
  244. package/dist/server/chat.d.ts +1 -1
  245. package/dist/server/chat.js +122 -31
  246. package/dist/server/chrome.d.ts +6 -1
  247. package/dist/server/chrome.js +18 -7
  248. package/dist/server/console.css +52 -38
  249. package/dist/server/flows.js +65 -13
  250. package/dist/server/guards.d.ts +1 -0
  251. package/dist/server/guards.js +23 -9
  252. package/dist/server/http.d.ts +7 -0
  253. package/dist/server/http.js +3 -2
  254. package/dist/server/live.d.ts +34 -1
  255. package/dist/server/live.js +39 -17
  256. package/dist/server/pages-assets.js +5 -0
  257. package/dist/server/pages.js +90 -13
  258. package/dist/server/people-tokens.d.ts +3 -0
  259. package/dist/server/people-tokens.js +55 -17
  260. package/dist/server/remote-hooks.js +12 -6
  261. package/dist/server/remote.d.ts +2 -1
  262. package/dist/server/remote.js +14 -5
  263. package/dist/server/render-chat.d.ts +12 -3
  264. package/dist/server/render-chat.js +47 -16
  265. package/dist/server/render-pages.d.ts +4 -1
  266. package/dist/server/render-pages.js +5 -4
  267. package/dist/server/render-people.d.ts +3 -2
  268. package/dist/server/render-people.js +3 -2
  269. package/dist/server/render-results.d.ts +18 -2
  270. package/dist/server/render-results.js +37 -28
  271. package/dist/server/render-settings.js +6 -5
  272. package/dist/server/render-tasks.d.ts +35 -10
  273. package/dist/server/render-tasks.js +110 -27
  274. package/dist/server/request-context.d.ts +2 -0
  275. package/dist/server/request-context.js +2 -1
  276. package/dist/server/route-table.d.ts +3 -0
  277. package/dist/server/route-table.js +49 -5
  278. package/dist/server/runtime.d.ts +34 -3
  279. package/dist/server/session.d.ts +25 -1
  280. package/dist/server/session.js +18 -4
  281. package/dist/server/settings.js +132 -13
  282. package/dist/server/sweeps.d.ts +4 -0
  283. package/dist/server/sweeps.js +58 -0
  284. package/dist/server/task-views.d.ts +2 -1
  285. package/dist/server/task-views.js +96 -6
  286. package/dist/server/tasks.js +320 -74
  287. package/dist/server/terminals.d.ts +31 -0
  288. package/dist/server/terminals.js +365 -0
  289. package/dist/setup-guide.js +3 -2
  290. package/dist/slack-chat.js +4 -1
  291. package/dist/spend-ui.js +3 -3
  292. package/dist/spend.js +2 -1
  293. package/dist/steer-capabilities.d.ts +32 -0
  294. package/dist/steer-capabilities.js +60 -0
  295. package/dist/steer-controls.d.ts +14 -0
  296. package/dist/steer-controls.js +36 -0
  297. package/dist/steer-status.js +2 -2
  298. package/dist/storage-sweep-child.js +3 -1
  299. package/dist/storage-sweep-process.d.ts +12 -5
  300. package/dist/storage-sweep-process.js +23 -6
  301. package/dist/storage-ui.js +4 -4
  302. package/dist/store-baseline.d.ts +1 -1
  303. package/dist/store-baseline.js +1 -1
  304. package/dist/store.d.ts +466 -19
  305. package/dist/store.js +1185 -63
  306. package/dist/subagent-desk.js +1 -1
  307. package/dist/subagent-tools.d.ts +1 -1
  308. package/dist/summary.js +3 -0
  309. package/dist/surface.js +6 -4
  310. package/dist/task-source.d.ts +58 -0
  311. package/dist/task-source.js +93 -0
  312. package/dist/team-leads.js +1 -1
  313. package/dist/teams-chat.js +4 -1
  314. package/dist/teardown-git.d.ts +14 -0
  315. package/dist/teardown-git.js +119 -0
  316. package/dist/telegram-lead.d.ts +3 -0
  317. package/dist/telegram-lead.js +44 -7
  318. package/dist/telegram.d.ts +11 -2
  319. package/dist/telegram.js +51 -17
  320. package/dist/terminal-catalog.d.ts +11 -0
  321. package/dist/terminal-catalog.js +129 -0
  322. package/dist/text-limits.d.ts +2 -0
  323. package/dist/text-limits.js +2 -0
  324. package/dist/tool-broker.d.ts +39 -0
  325. package/dist/tool-broker.js +370 -0
  326. package/dist/tool-launcher.js +5 -4
  327. package/dist/tool-secret-store.d.ts +170 -0
  328. package/dist/tool-secret-store.js +834 -0
  329. package/dist/toolroll-update-ui.js +2 -2
  330. package/dist/toolroll-update.js +14 -3
  331. package/dist/tools-ui.d.ts +7 -1
  332. package/dist/tools-ui.js +19 -10
  333. package/dist/webhook-limits.d.ts +23 -0
  334. package/dist/webhook-limits.js +30 -0
  335. package/dist/webhook-readers.d.ts +56 -0
  336. package/dist/webhook-readers.js +138 -0
  337. package/dist/webhook-signing.d.ts +27 -0
  338. package/dist/webhook-signing.js +119 -0
  339. package/dist/work-index.d.ts +29 -0
  340. package/dist/work-index.js +21 -5
  341. package/dist/workspace-revision.js +3 -1
  342. package/dist/worktree.d.ts +10 -4
  343. package/dist/worktree.js +70 -29
  344. package/package.json +13 -2
  345. package/scripts/provider-canary.mjs +210 -31
@@ -0,0 +1,834 @@
1
+ /**
2
+ * Where the secrets Toolroll holds rest — project tools' keys and sign-ins,
3
+ * and the credentials it keeps for integrations (Linear, Google mail, flow
4
+ * webhooks): the operating system's keychain where this computer has one,
5
+ * otherwise owner-only files.
6
+ *
7
+ * - macOS: the Keychain, through `/usr/bin/security -i`. Every command, the
8
+ * value included, is written to its stdin, so no secret is ever in a
9
+ * process's arguments. A line there holds about 4 KB, so a value is split
10
+ * into chunks: a head item names the chunks' generation and checksum, and is
11
+ * written last, so a reader sees the old value or the new one, never half.
12
+ * - Linux: the Secret Service, through `secret-tool` (found in the system's
13
+ * own folders, never on a PATH), which reads the value from stdin. One that
14
+ * is installed but doesn't answer (no session bus) means files, said so.
15
+ * - Anywhere else (or with TOOLROLL_SECRET_STORE=files): owner-only files, as
16
+ * before. Settings and `toolroll integrations` say so plainly.
17
+ *
18
+ * A keychain that is present but fails (locked, refused) is an error, never a
19
+ * quiet fall back to files: what can't be read leaves the tool out of a run.
20
+ *
21
+ * Everything here lives in Toolroll's own folder for secrets: `tool-secrets`
22
+ * beside the database, made when Toolroll starts (before any build can run),
23
+ * never found by looking for a folder that happens to exist. A build is the
24
+ * same user and can reach the keychain too (the fence can't deny it), so what
25
+ * Toolroll keeps there is sealed (AES-256-GCM) with a key in that folder,
26
+ * which every build's fence denies, and bound to its item's address. The
27
+ * database records which key Toolroll made: a folder, key, link or lock that
28
+ * something else put there first is refused, never used.
29
+ */
30
+ import { spawnSync } from "node:child_process";
31
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
32
+ import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeSync } from "node:fs";
33
+ import { dirname, join } from "node:path";
34
+ import { envValue } from "./names.js";
35
+ export class SecretStoreError extends Error {
36
+ }
37
+ /** The words Settings and `toolroll integrations` use for where secrets rest. */
38
+ export const STORE_WORDS = {
39
+ "macos-keychain": "Secrets are kept in the macOS Keychain.",
40
+ "secret-service": "Secrets are kept in this computer's keyring (Secret Service).",
41
+ files: "Secrets are protected by owner-only files on this computer.",
42
+ };
43
+ /** Said instead when a keyring is installed but doesn't answer, so files are all there is. */
44
+ export const UNANSWERED_WORDS = "Secrets are protected by owner-only files on this computer: its keyring isn't answering.";
45
+ const SAFE = /^[A-Za-z0-9._-]+$/;
46
+ const LABEL = "Toolroll tool secret";
47
+ /** What the store's own program gets: enough to find the user's keychain or session bus, nothing else. */
48
+ const STORE_ENV = ["PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"];
49
+ const storeEnv = () => Object.fromEntries(STORE_ENV.flatMap(name => process.env[name] === undefined ? [] : [[name, process.env[name]]]));
50
+ const checked = (item) => {
51
+ if (!SAFE.test(item.service) || !SAFE.test(item.account))
52
+ throw new SecretStoreError("A secret's address can't be used.");
53
+ };
54
+ /** The macOS Keychain, through `security -i` (`keychain`: a keychain file instead of the user's own, for tests). */
55
+ export function macosKeychain(options = {}) {
56
+ const binary = options.binary ?? "/usr/bin/security";
57
+ if (options.keychain !== undefined && !/^\/[A-Za-z0-9._/-]+$/.test(options.keychain))
58
+ throw new SecretStoreError("That keychain path can't be used.");
59
+ const where = options.keychain === undefined ? "" : ` ${options.keychain}`;
60
+ /** One command on stdin; `security -i` exits with that command's status. */
61
+ const run = (command) => {
62
+ const done = spawnSync(binary, ["-i"], { input: `${command}\n`, encoding: "utf8", timeout: 20_000, env: storeEnv(), stdio: ["pipe", "pipe", "pipe"] });
63
+ if (done.error !== undefined)
64
+ throw new SecretStoreError("The macOS Keychain couldn't be reached.");
65
+ return { code: done.status, stdout: done.stdout };
66
+ };
67
+ const named = (item) => { checked(item); return `-a ${item.account} -s ${item.service}`; };
68
+ return {
69
+ kind: "macos-keychain",
70
+ limit: 3_000,
71
+ get(item) {
72
+ const done = run(`find-generic-password ${named(item)} -w${where}`);
73
+ if (done.code === 44)
74
+ return null;
75
+ if (done.code !== 0)
76
+ throw new SecretStoreError("The macOS Keychain refused to read a tool's secrets. Unlock it and try again.");
77
+ return done.stdout.replace(/\n$/, "");
78
+ },
79
+ set(item, value, setOptions = {}) {
80
+ if (!SAFE.test(value) || value.length > this.limit)
81
+ throw new SecretStoreError("A secret's stored form can't be used.");
82
+ const done = run(`add-generic-password ${setOptions.create === true ? "" : "-U "}${named(item)} -l "${LABEL}" -w ${value}${where}`);
83
+ if (done.code === 45 && setOptions.create === true)
84
+ return "exists";
85
+ if (done.code !== 0)
86
+ throw new SecretStoreError("The macOS Keychain refused to save a tool's secrets. Unlock it and try again.");
87
+ return "ok";
88
+ },
89
+ remove(item) {
90
+ const done = run(`delete-generic-password ${named(item)}${where}`);
91
+ if (done.code !== 0 && done.code !== 44)
92
+ throw new SecretStoreError("The macOS Keychain refused to remove a tool's secrets.");
93
+ },
94
+ };
95
+ }
96
+ /** The Secret Service (GNOME Keyring, KWallet), through `secret-tool` at `binary`, an absolute path; the value goes on its stdin. */
97
+ export function secretService(options) {
98
+ const binary = options.binary;
99
+ if (!binary.startsWith("/"))
100
+ throw new SecretStoreError("The keyring's program must be named by its full path.");
101
+ const run = (args, input) => {
102
+ const done = spawnSync(binary, args, { input: input ?? "", encoding: "utf8", timeout: 20_000, env: storeEnv(), stdio: ["pipe", "pipe", "pipe"] });
103
+ if (done.error !== undefined)
104
+ throw new SecretStoreError("This computer's keyring couldn't be reached.");
105
+ return { code: done.status, stdout: done.stdout, stderr: done.stderr };
106
+ };
107
+ const named = (item) => { checked(item); return ["service", item.service, "account", item.account]; };
108
+ const backend = {
109
+ kind: "secret-service",
110
+ limit: 1_000_000,
111
+ get(item) {
112
+ const done = run(["lookup", ...named(item)]);
113
+ if (done.code === 0)
114
+ return done.stdout.replace(/\n$/, "");
115
+ if (done.code === 1 && done.stdout === "" && done.stderr.trim() === "")
116
+ return null;
117
+ throw new SecretStoreError("This computer's keyring refused to read a tool's secrets. Unlock it and try again.");
118
+ },
119
+ set(item, value, setOptions = {}) {
120
+ if (!SAFE.test(value))
121
+ throw new SecretStoreError("A secret's stored form can't be used.");
122
+ if (setOptions.create === true && backend.get(item) !== null)
123
+ return "exists";
124
+ if (run(["store", `--label=${LABEL}`, ...named(item)], value).code !== 0)
125
+ throw new SecretStoreError("This computer's keyring refused to save a tool's secrets. Unlock it and try again.");
126
+ return "ok";
127
+ },
128
+ remove(item) {
129
+ const done = run(["clear", ...named(item)]);
130
+ if (done.code !== 0 && !(done.code === 1 && done.stderr.trim() === ""))
131
+ throw new SecretStoreError("This computer's keyring refused to remove a tool's secrets.");
132
+ },
133
+ };
134
+ return backend;
135
+ }
136
+ const myUid = () => process.getuid?.() ?? null;
137
+ /** Writable by someone other than its owner, and not a shared sticky folder like /tmp. */
138
+ const othersWrite = (info) => (info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0;
139
+ /**
140
+ * `path` when it is a program only the system (or, for a test's stand-in, one of `owners`) can have put there: a real
141
+ * executable file nobody else can change, in folders nobody else can either. Null otherwise.
142
+ */
143
+ export function trustedProgram(path, owners = [0]) {
144
+ try {
145
+ const real = realpathSync(path);
146
+ const info = statSync(real);
147
+ if (!info.isFile() || (info.mode & 0o111) === 0 || (info.mode & 0o022) !== 0 || !owners.includes(info.uid))
148
+ return null;
149
+ for (let folder = dirname(real);; folder = dirname(folder)) {
150
+ const one = lstatSync(folder);
151
+ if ((one.uid !== 0 && !owners.includes(one.uid)) || othersWrite(one))
152
+ return null;
153
+ if (folder === dirname(folder))
154
+ break;
155
+ }
156
+ return real;
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ /** Where `secret-tool` may be: the system's own folders, never the PATH a process was given. */
163
+ export const SECRET_TOOL_PATHS = ["/usr/bin/secret-tool", "/bin/secret-tool", "/usr/local/bin/secret-tool"];
164
+ /**
165
+ * Find this computer's store: the macOS Keychain where the system's `security` is; a Secret Service where the system's
166
+ * `secret-tool` is and answers; else none (files). A `secret-tool` that is there but can't reach a keyring (no session
167
+ * bus) is none too, and says so.
168
+ */
169
+ export function detectSecretStore(options = {}) {
170
+ const platform = options.platform ?? process.platform, owners = options.owners ?? [0];
171
+ if (platform === "darwin") {
172
+ const security = trustedProgram("/usr/bin/security", owners);
173
+ return { backend: security === null ? null : macosKeychain({ binary: security }), unanswered: false };
174
+ }
175
+ if (platform !== "linux")
176
+ return { backend: null, unanswered: false };
177
+ const tool = (options.secretTools ?? SECRET_TOOL_PATHS).map(path => trustedProgram(path, owners)).find((one) => one !== null);
178
+ if (tool === undefined)
179
+ return { backend: null, unanswered: false };
180
+ // Answering: a lookup of nothing says "not found" quietly; no session bus says why on stderr, or hangs.
181
+ const probe = spawnSync(tool, ["lookup", "service", "toolroll-probe", "account", "probe"], { encoding: "utf8", timeout: 5_000, env: storeEnv(), stdio: ["ignore", "pipe", "pipe"] });
182
+ const answers = probe.error === undefined && (probe.status === 0 || (probe.status === 1 && probe.stderr.trim() === ""));
183
+ return answers ? { backend: secretService({ binary: tool }), unanswered: false } : { backend: null, unanswered: true };
184
+ }
185
+ let detected;
186
+ let chosen = null;
187
+ /** Tests choose the store (a keychain file of their own, files, or what detection found); null goes back to this computer's. */
188
+ export function chooseSecretStore(store) { chosen = store; }
189
+ function currentStore(env) {
190
+ if (chosen === "files")
191
+ return { backend: null, unanswered: false };
192
+ if (chosen !== null)
193
+ return "kind" in chosen ? { backend: chosen, unanswered: false } : chosen;
194
+ if (envValue(env, "SECRET_STORE") === "files")
195
+ return { backend: null, unanswered: false };
196
+ detected ??= detectSecretStore();
197
+ return detected;
198
+ }
199
+ /** This computer's store (found once), or null for owner-only files. */
200
+ export function secretBackend(env = process.env) {
201
+ return currentStore(env).backend;
202
+ }
203
+ export const secretStoreKind = () => secretBackend()?.kind ?? "files";
204
+ /** Where secrets rest, in the words Settings and `toolroll integrations` share; `problem` when Toolroll's folder can't be used. */
205
+ export function secretStorage() {
206
+ const found = currentStore(process.env);
207
+ const kind = found.backend?.kind ?? "files";
208
+ const problem = standing === null ? standingProblem : refusedRoots.get(standing) ?? null;
209
+ return { kind, words: found.unanswered ? UNANSWERED_WORDS : STORE_WORDS[kind], filesOnly: kind === "files", ...(problem === null ? {} : { problem }) };
210
+ }
211
+ // ---- a value, in chunks the store can hold ------------------------------------------------------------------------
212
+ const HEAD = /^v1\.([0-9a-f]{8})\.([1-9][0-9]?)\.([0-9a-f]{16})\.([A-Za-z0-9_-]*)$/;
213
+ const digestOf = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
214
+ const chunkItem = (item, generation, index) => ({ service: item.service, account: `${item.account}.${generation}.${index}` });
215
+ function readHead(backend, item) {
216
+ const head = backend.get(item);
217
+ if (head === null)
218
+ return null;
219
+ const parts = HEAD.exec(head);
220
+ if (parts === null)
221
+ throw new SecretStoreError("A tool's stored secrets are damaged.");
222
+ return { generation: parts[1], count: Number(parts[2]), digest: parts[3], first: parts[4] };
223
+ }
224
+ /** The text stored at `item`, or null when nothing is. A head read while a writer replaces it is read again. */
225
+ export function readValue(backend, item) {
226
+ for (let attempt = 0;; attempt++) {
227
+ const head = readHead(backend, item);
228
+ if (head === null)
229
+ return null;
230
+ const chunks = [head.first];
231
+ // Each chunk starts "c." so none begins with "-" and reads as an option.
232
+ for (let index = 1; index < head.count; index++)
233
+ chunks.push(/^c\.([A-Za-z0-9_-]+)$/.exec(backend.get(chunkItem(item, head.generation, index)) ?? "")?.[1] ?? "\u0000");
234
+ const text = chunks.join("");
235
+ if (!text.includes("\u0000") && digestOf(text) === head.digest)
236
+ return Buffer.from(text, "base64url").toString("utf8");
237
+ if (attempt >= 2)
238
+ throw new SecretStoreError("A tool's stored secrets are damaged.");
239
+ }
240
+ }
241
+ /**
242
+ * Store `text` at `item`: the chunks of a new generation first, then the head that names them, then the old
243
+ * generation's chunks are removed. `create`: only when nothing is stored there yet.
244
+ */
245
+ export function writeValue(backend, item, text, options = {}) {
246
+ const encoded = Buffer.from(text, "utf8").toString("base64url");
247
+ const generation = randomBytes(4).toString("hex");
248
+ const firstRoom = backend.limit - 40;
249
+ const chunks = [encoded.slice(0, firstRoom), ...(encoded.slice(firstRoom).match(new RegExp(`.{1,${backend.limit - 2}}`, "g")) ?? [])];
250
+ if (chunks.length > 99)
251
+ throw new SecretStoreError("A tool's secrets are too large to store.");
252
+ const before = options.create === true ? null : readHead(backend, item);
253
+ for (let index = 1; index < chunks.length; index++)
254
+ backend.set(chunkItem(item, generation, index), `c.${chunks[index]}`, { create: true });
255
+ const placed = backend.set(item, `v1.${generation}.${chunks.length}.${digestOf(encoded)}.${chunks[0]}`, options);
256
+ const stale = placed === "exists" ? { generation, count: chunks.length } : before;
257
+ if (stale !== null)
258
+ for (let index = 1; index < stale.count; index++)
259
+ backend.remove(chunkItem(item, stale.generation, index));
260
+ return placed;
261
+ }
262
+ /** Remove what is stored at `item`, every chunk with it. */
263
+ export function removeValue(backend, item) {
264
+ let head = null;
265
+ try {
266
+ head = readHead(backend, item);
267
+ }
268
+ catch {
269
+ head = null;
270
+ }
271
+ backend.remove(item);
272
+ if (head !== null)
273
+ for (let index = 1; index < head.count; index++)
274
+ backend.remove(chunkItem(item, head.generation, index));
275
+ }
276
+ // ---- Toolroll's own folder for secrets ----------------------------------------------------------------------------
277
+ /** The folder's name, beside the database. */
278
+ export const SECRET_FOLDER = "tool-secrets";
279
+ const KEY_FILE = ".keychain-key";
280
+ /** An anchor kept in this process alone: a test's own folder. */
281
+ export function memoryAnchor() {
282
+ let held = null;
283
+ return { read: () => held, swap: (expected, next) => { if (held !== expected)
284
+ return false; held = next; return true; } };
285
+ }
286
+ const folders = new Map();
287
+ /** Roots whose folder was refused when Toolroll started: never prepared again in this process. */
288
+ const refusedRoots = new Map();
289
+ let standing = null;
290
+ let standingProblem = null;
291
+ const NOT_READY = "Toolroll's folder for secrets isn't ready. Restart Toolroll and try again.";
292
+ const keyDigest = (key) => createHash("sha256").update("toolroll:sealing-key:v1\u0000").update(key).digest("hex");
293
+ const mine = (info) => { const uid = myUid(); return uid === null || info.uid === uid; };
294
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
295
+ const codeOf = (error) => error.code;
296
+ const refuse = (path, why, fix = "Remove it and restart Toolroll.") => { throw new SecretStoreError(`Secrets are switched off: ${path} ${why}. ${fix}`); };
297
+ /** Every folder from `root` up: real folders, this user's or the system's, that nobody else can write. */
298
+ function checkAncestors(root) {
299
+ for (let folder = root;; folder = dirname(folder)) {
300
+ const info = lstatSync(folder);
301
+ if (!info.isDirectory())
302
+ refuse(folder, "isn't a folder");
303
+ if (!(mine(info) || info.uid === 0) || othersWrite(info))
304
+ refuse(folder, "can be changed by other users");
305
+ if (folder === dirname(folder))
306
+ return;
307
+ }
308
+ }
309
+ /** A folder of Toolroll's own: a real folder (never a link), this user's, owner-only. */
310
+ function checkOwnFolder(path, info) {
311
+ if (info.isSymbolicLink() || !info.isDirectory())
312
+ refuse(path, "isn't a folder Toolroll made");
313
+ if (!mine(info) || (info.mode & 0o077) !== 0)
314
+ refuse(path, "isn't owner-only");
315
+ }
316
+ /** `path` opened without following a link, as a plain owner-only file with one name; null when there is none. */
317
+ function openOwnFile(path, flags) {
318
+ let fd;
319
+ try {
320
+ fd = openSync(path, flags | constants.O_NOFOLLOW);
321
+ }
322
+ catch (error) {
323
+ if (codeOf(error) === "ENOENT")
324
+ return null;
325
+ if (codeOf(error) === "ELOOP")
326
+ refuse(path, "is a link");
327
+ throw new SecretStoreError("A file of Toolroll's secrets can't be read.");
328
+ }
329
+ const info = fstatSync(fd);
330
+ const problem = !info.isFile() ? "isn't a plain file" : !mine(info) ? "isn't this user's"
331
+ : (info.mode & 0o077) !== 0 ? "is readable by other users" : info.nlink !== 1 ? "has another name (a hard link)" : null;
332
+ if (problem !== null) {
333
+ closeSync(fd);
334
+ refuse(path, problem, problem === "is readable by other users" ? "Make it owner-only (chmod 600) and restart Toolroll." : undefined);
335
+ }
336
+ return { fd, info };
337
+ }
338
+ /** The key in `file`: 32 bytes, read through openOwnFile; null when there is none. */
339
+ function readKey(file) {
340
+ const opened = openOwnFile(file, constants.O_RDONLY);
341
+ if (opened === null)
342
+ return null;
343
+ try {
344
+ const key = Buffer.alloc(33);
345
+ if (readSync(opened.fd, key, 0, 33, 0) !== 32)
346
+ refuse(file, "is damaged");
347
+ return key.subarray(0, 32);
348
+ }
349
+ finally {
350
+ closeSync(opened.fd);
351
+ }
352
+ }
353
+ /** One preparer at a time, across processes: a lock file beside the folder, taken over when a dead one left it. */
354
+ function withRootLock(root, work) {
355
+ const lock = join(root, `.${SECRET_FOLDER}.lock`);
356
+ for (let tries = 0;; tries++) {
357
+ try {
358
+ closeSync(openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600));
359
+ break;
360
+ }
361
+ catch (error) {
362
+ if (codeOf(error) !== "EEXIST")
363
+ refuse(lock, "can't be made");
364
+ try {
365
+ const info = lstatSync(lock);
366
+ if (Date.now() - info.mtimeMs > 30_000) {
367
+ unlinkSync(lock);
368
+ continue;
369
+ }
370
+ }
371
+ catch {
372
+ continue;
373
+ }
374
+ if (tries > 400)
375
+ throw new SecretStoreError("Toolroll's folder for secrets is being prepared elsewhere. Try again in a moment.");
376
+ pause(25);
377
+ }
378
+ }
379
+ try {
380
+ return work();
381
+ }
382
+ finally {
383
+ rmSync(lock, { force: true });
384
+ }
385
+ }
386
+ function openFolder(stateDir, anchor, create, legacy) {
387
+ let root;
388
+ try {
389
+ root = realpathSync(stateDir);
390
+ }
391
+ catch {
392
+ throw new SecretStoreError("Toolroll's own folder can't be found, so the secrets it holds can't be used.");
393
+ }
394
+ checkAncestors(root);
395
+ const dir = join(root, SECRET_FOLDER), keyFile = join(dir, KEY_FILE);
396
+ const known = folders.get(root);
397
+ return withRootLock(root, () => {
398
+ let info = null;
399
+ try {
400
+ info = lstatSync(dir);
401
+ }
402
+ catch (error) {
403
+ if (codeOf(error) !== "ENOENT")
404
+ refuse(dir, "can't be read");
405
+ }
406
+ const recorded = anchor.read();
407
+ if (info === null) {
408
+ if (!create)
409
+ throw new SecretStoreError(NOT_READY);
410
+ mkdirSync(dir, { mode: 0o700 });
411
+ info = lstatSync(dir);
412
+ }
413
+ else if (recorded === null && known === undefined && readdirSync(dir).length > 0) {
414
+ // Toolroll never made a key here, yet the folder holds something: put there by something else.
415
+ refuse(dir, "wasn't made by Toolroll");
416
+ }
417
+ checkOwnFolder(dir, info);
418
+ let key = readKey(keyFile);
419
+ if (key !== null) {
420
+ const digest = keyDigest(key);
421
+ // This process made it (a test's own folder, then Toolroll's start): recorded now.
422
+ if (recorded === null && known?.digest === digest)
423
+ anchor.swap(null, digest);
424
+ else if (digest !== recorded)
425
+ refuse(keyFile, "isn't the key Toolroll made");
426
+ }
427
+ else {
428
+ if (!create)
429
+ throw new SecretStoreError(NOT_READY);
430
+ // A new key: recorded first, then put in place whole (a crash in between leaves no key, and the next start makes one).
431
+ key = randomBytes(32);
432
+ if (!anchor.swap(recorded, keyDigest(key)))
433
+ throw new SecretStoreError("Toolroll's folder for secrets is being prepared elsewhere. Try again in a moment.");
434
+ const staged = join(dir, `${KEY_FILE}.${process.pid}.${randomBytes(4).toString("hex")}`);
435
+ const fd = openSync(staged, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
436
+ try {
437
+ writeSync(fd, key);
438
+ fsyncSync(fd);
439
+ }
440
+ finally {
441
+ closeSync(fd);
442
+ }
443
+ try {
444
+ linkSync(staged, keyFile);
445
+ }
446
+ catch {
447
+ rmSync(staged, { force: true });
448
+ refuse(keyFile, "appeared while Toolroll was making it");
449
+ }
450
+ rmSync(staged, { force: true });
451
+ }
452
+ return { root, dir, legacy, dev: info.dev, ino: info.ino, key, digest: keyDigest(key), anchor };
453
+ });
454
+ }
455
+ /**
456
+ * Prepare Toolroll's folder for secrets beside `stateDir` (the folder holding the database), checked against what
457
+ * `anchor` recorded: made with its sealing key when it isn't there yet (`create`), refused when something else put a
458
+ * folder, key or link there. `standing`: the folder this process uses by default (Toolroll's own start).
459
+ */
460
+ export function prepareSecretFolder(stateDir, anchor, options = {}) {
461
+ let root = null;
462
+ try {
463
+ root = realpathSync(stateDir);
464
+ const folder = openFolder(root, anchor, options.create ?? true, options.legacy ?? null);
465
+ folders.set(folder.root, folder);
466
+ refusedRoots.delete(folder.root);
467
+ if (options.standing === true) {
468
+ standing = folder.root;
469
+ standingProblem = null;
470
+ }
471
+ return { ok: true, dir: folder.dir };
472
+ }
473
+ catch (error) {
474
+ const problem = error instanceof SecretStoreError ? error.message : "Toolroll's folder for secrets couldn't be prepared.";
475
+ if (root !== null) {
476
+ folders.delete(root);
477
+ refusedRoots.set(root, problem);
478
+ }
479
+ if (options.standing === true) {
480
+ standing = root;
481
+ standingProblem = problem;
482
+ }
483
+ return { ok: false, problem };
484
+ }
485
+ }
486
+ /** What a folder beside a database will be checked against, before it is first used (registerSecretFolder). */
487
+ const pending = new Map();
488
+ /**
489
+ * Name Toolroll's folder for secrets (beside `stateDir`, the database's folder) and the record to check it against,
490
+ * without touching it yet: it is prepared the first time a secret is used. `standing`: this process's default.
491
+ */
492
+ export function registerSecretFolder(stateDir, anchor, options = {}) {
493
+ let root;
494
+ try {
495
+ root = realpathSync(stateDir);
496
+ }
497
+ catch {
498
+ standing = null;
499
+ standingProblem = "Toolroll's own folder can't be found, so the secrets it holds can't be used.";
500
+ return;
501
+ }
502
+ pending.set(root, { anchor, legacy: options.legacy ?? null });
503
+ standing = root;
504
+ standingProblem = null;
505
+ }
506
+ /** Still the folder that was checked: the same folder, still owner-only, its key unchanged. */
507
+ function unchanged(folder) {
508
+ try {
509
+ const info = lstatSync(folder.dir);
510
+ if (info.isSymbolicLink() || !info.isDirectory() || info.dev !== folder.dev || info.ino !== folder.ino || !mine(info) || (info.mode & 0o077) !== 0)
511
+ return false;
512
+ const key = readKey(join(folder.dir, KEY_FILE));
513
+ return key !== null && keyDigest(key) === folder.digest;
514
+ }
515
+ catch {
516
+ return false;
517
+ }
518
+ }
519
+ /**
520
+ * Toolroll's folder for secrets, checked again now: the one prepared at start when `state` is absent, else the one
521
+ * beside `state` (a test's own folder is prepared the first time it is used, against an anchor in this process).
522
+ */
523
+ export function secretFolder(state) {
524
+ let root;
525
+ if (state === undefined) {
526
+ if (standing === null)
527
+ throw new SecretStoreError(standingProblem ?? NOT_READY);
528
+ root = standing;
529
+ }
530
+ else {
531
+ try {
532
+ root = realpathSync(state);
533
+ }
534
+ catch {
535
+ throw new SecretStoreError("Toolroll's own folder can't be found, so the secrets it holds can't be used.");
536
+ }
537
+ }
538
+ const problem = refusedRoots.get(root);
539
+ if (problem !== undefined)
540
+ throw new SecretStoreError(problem);
541
+ const known = folders.get(root);
542
+ if (known !== undefined && unchanged(known))
543
+ return known;
544
+ // Changed since it was checked (or never seen): checked against the same record (the database's, for Toolroll's
545
+ // own folder; this process's, for a test's), and refused if it doesn't match.
546
+ const named = pending.get(root);
547
+ const again = prepareSecretFolder(root, known?.anchor ?? named?.anchor ?? memoryAnchor(), { create: true, legacy: known?.legacy ?? named?.legacy ?? null, standing: root === standing });
548
+ if (!again.ok)
549
+ throw new SecretStoreError(again.problem);
550
+ return folders.get(root);
551
+ }
552
+ /** Forget every prepared folder (tests). */
553
+ export function forgetSecretFolders() { folders.clear(); refusedRoots.clear(); pending.clear(); standing = null; standingProblem = null; }
554
+ /** The keychain service for everything kept for this folder's Toolroll: its folder, hashed. Never a secret. */
555
+ export const serviceOf = (folder) => `toolroll-tool-secrets.${createHash("sha256").update(folder.root).digest("hex").slice(0, 12)}`;
556
+ // ---- files inside it ---------------------------------------------------------------------------------------------
557
+ /**
558
+ * A path inside `base` (the folder, or Toolroll's own folder above it), each folder on the way checked: a real folder
559
+ * of this user's that nobody else can write, never a link. Made owner-only when `make` (else a missing one ends the
560
+ * walk: nothing is there).
561
+ */
562
+ export function pathIn(base, parts, options = {}) {
563
+ for (const part of parts)
564
+ if (!SAFE.test(part) || part === "." || part === "..")
565
+ throw new SecretStoreError("A secret's address can't be used.");
566
+ let at = base;
567
+ for (const part of parts.slice(0, -1)) {
568
+ at = join(at, part);
569
+ let info = null;
570
+ try {
571
+ info = lstatSync(at);
572
+ }
573
+ catch (error) {
574
+ if (codeOf(error) !== "ENOENT")
575
+ refuse(at, "can't be read");
576
+ }
577
+ if (info === null) {
578
+ if (options.make !== true)
579
+ return join(base, ...parts);
580
+ try {
581
+ mkdirSync(at, { mode: 0o700 });
582
+ }
583
+ catch (error) {
584
+ if (codeOf(error) !== "EEXIST")
585
+ throw error;
586
+ }
587
+ info = lstatSync(at);
588
+ }
589
+ if (info.isSymbolicLink() || !info.isDirectory())
590
+ refuse(at, "isn't a folder Toolroll made");
591
+ if (!mine(info) || (info.mode & 0o022) !== 0)
592
+ refuse(at, "can be changed by other users");
593
+ }
594
+ return join(base, ...parts);
595
+ }
596
+ /** A file's text, read without following a link; null when there is none. Anything not plainly Toolroll's is refused. */
597
+ export function readOwnFile(path) {
598
+ const opened = openOwnFile(path, constants.O_RDONLY);
599
+ if (opened === null)
600
+ return null;
601
+ try {
602
+ const bytes = Buffer.alloc(opened.info.size);
603
+ let read = 0;
604
+ while (read < bytes.length) {
605
+ const got = readSync(opened.fd, bytes, read, bytes.length - read, read);
606
+ if (got === 0)
607
+ break;
608
+ read += got;
609
+ }
610
+ return bytes.subarray(0, read).toString("utf8");
611
+ }
612
+ finally {
613
+ closeSync(opened.fd);
614
+ }
615
+ }
616
+ /** Write a file whole and owner-only: a new file beside it, then renamed over it, so a link or second name planted there is replaced, never written through. */
617
+ export function writeOwnFile(path, text) {
618
+ const staged = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.new`;
619
+ const fd = openSync(staged, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
620
+ try {
621
+ writeSync(fd, text);
622
+ fsyncSync(fd);
623
+ }
624
+ finally {
625
+ closeSync(fd);
626
+ }
627
+ renameSync(staged, path);
628
+ }
629
+ /** A file's bytes overwritten, then the file removed (a link is only removed, never followed). */
630
+ export function destroyOwnFile(path) {
631
+ try {
632
+ const opened = openOwnFile(path, constants.O_WRONLY);
633
+ if (opened !== null)
634
+ try {
635
+ writeSync(opened.fd, Buffer.alloc(opened.info.size));
636
+ fsyncSync(opened.fd);
637
+ }
638
+ finally {
639
+ closeSync(opened.fd);
640
+ }
641
+ }
642
+ catch { /* removed below either way */ }
643
+ rmSync(path, { force: true });
644
+ try {
645
+ lstatSync(path);
646
+ throw new SecretStoreError("A secrets file couldn't be removed after its move to the keychain.");
647
+ }
648
+ catch (error) {
649
+ if (error instanceof SecretStoreError)
650
+ throw error;
651
+ }
652
+ }
653
+ const lockFile = (folder, name) => pathIn(folder.dir, [".locks", `${name}.lock`], { make: true });
654
+ const takeLock = (lock, staleMs) => {
655
+ try {
656
+ closeSync(openSync(lock, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600));
657
+ return true;
658
+ }
659
+ catch (error) {
660
+ if (codeOf(error) !== "EEXIST")
661
+ throw new SecretStoreError("A lock for Toolroll's secrets can't be made.");
662
+ // A lock left by a process that died is old: taken over.
663
+ try {
664
+ if (Date.now() - lstatSync(lock).mtimeMs > staleMs)
665
+ unlinkSync(lock);
666
+ }
667
+ catch { /* gone already */ }
668
+ return false;
669
+ }
670
+ };
671
+ const held = new Map();
672
+ /** One change at a time to what `name` keeps, across Toolroll's processes (a lock file in the folder), re-entrant within one. */
673
+ export function withSecretLock(folder, name, work) {
674
+ const lock = lockFile(folder, name);
675
+ if ((held.get(lock) ?? 0) > 0) {
676
+ held.set(lock, held.get(lock) + 1);
677
+ try {
678
+ return work();
679
+ }
680
+ finally {
681
+ held.set(lock, held.get(lock) - 1);
682
+ }
683
+ }
684
+ for (let tries = 0; !takeLock(lock, 30_000); tries++) {
685
+ if (tries > 400)
686
+ throw new SecretStoreError("A tool's secrets are being changed elsewhere. Try again in a moment.");
687
+ pause(25);
688
+ }
689
+ held.set(lock, 1);
690
+ try {
691
+ return work();
692
+ }
693
+ finally {
694
+ held.delete(lock);
695
+ rmSync(lock, { force: true });
696
+ }
697
+ }
698
+ /** The same, for work that waits on the network (renewing a sign-in): other processes wait for it, up to `waitMs`. */
699
+ export async function withSecretLockAsync(folder, name, work, options = {}) {
700
+ const lock = lockFile(folder, name), started = Date.now();
701
+ while (!takeLock(lock, options.staleMs ?? 120_000)) {
702
+ if (Date.now() - started > (options.waitMs ?? 120_000))
703
+ throw new SecretStoreError("A sign-in is being renewed elsewhere. Try again in a moment.");
704
+ await new Promise(resolve => setTimeout(resolve, 25));
705
+ }
706
+ try {
707
+ return await work();
708
+ }
709
+ finally {
710
+ rmSync(lock, { force: true });
711
+ }
712
+ }
713
+ // ---- sealed with the folder's key ----------------------------------------------------------------------------------
714
+ const SEALED = /^w1\.([A-Za-z0-9_-]+)$/;
715
+ const aad = (item) => Buffer.from(`toolroll:tool-secret:v1:${item.service}/${item.account}`);
716
+ /** `text` sealed for `item` with the folder's key. */
717
+ export function seal(text, item, folder) {
718
+ const iv = randomBytes(12);
719
+ const cipher = createCipheriv("aes-256-gcm", folder.key, iv).setAAD(aad(item));
720
+ const body = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
721
+ return `w1.${Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64url")}`;
722
+ }
723
+ /** What `seal` sealed for `item`; refused when it was sealed for another item, with another key, or changed. */
724
+ export function unseal(sealed, item, folder) {
725
+ const raw = SEALED.exec(sealed)?.[1];
726
+ if (raw === undefined)
727
+ throw new SecretStoreError("A tool's stored secrets can't be opened; set them again on the Tools page.");
728
+ const bytes = Buffer.from(raw, "base64url");
729
+ try {
730
+ const decipher = createDecipheriv("aes-256-gcm", folder.key, bytes.subarray(0, 12)).setAAD(aad(item));
731
+ decipher.setAuthTag(bytes.subarray(12, 28));
732
+ return Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString("utf8");
733
+ }
734
+ catch {
735
+ throw new SecretStoreError("A tool's stored secrets can't be opened; set them again on the Tools page.");
736
+ }
737
+ }
738
+ /** The sealed text kept at `item`, opened; null when nothing is. */
739
+ export function readSealed(backend, folder, item) {
740
+ const text = readValue(backend, item);
741
+ return text === null ? null : unseal(text, item, folder);
742
+ }
743
+ /** Keep `text` at `item`, sealed. */
744
+ export function writeSealed(backend, folder, item, text, options = {}) {
745
+ return writeValue(backend, item, seal(text, item, folder), options);
746
+ }
747
+ // ---- what Toolroll holds for integrations -------------------------------------------------------------------------
748
+ const HELD_NAME = /^[a-z][a-z0-9-]{0,40}(\.[0-9]{1,12})?$/;
749
+ const heldItem = (folder, name) => {
750
+ if (!HELD_NAME.test(name))
751
+ throw new SecretStoreError("A secret's address can't be used.");
752
+ return { service: serviceOf(folder), account: `held.${name}` };
753
+ };
754
+ /**
755
+ * A secret's file, as it was kept before the keychain, moved in: stored, read back and compared, and only then is the
756
+ * file destroyed. What the keychain already holds (an earlier move, or a change since) wins over the file. A failure
757
+ * leaves the file as it was, and says so.
758
+ */
759
+ function moveHeldIn(backend, folder, name, file) {
760
+ // A link, or a file not plainly Toolroll's, put where the old file was: kept as it is, and never used.
761
+ let before;
762
+ try {
763
+ before = readOwnFile(file);
764
+ }
765
+ catch {
766
+ before = null;
767
+ }
768
+ if (before === null)
769
+ return;
770
+ const item = heldItem(folder, name);
771
+ if (writeSealed(backend, folder, item, before, { create: true }) === "ok") {
772
+ let back = null;
773
+ try {
774
+ back = readSealed(backend, folder, item);
775
+ }
776
+ catch {
777
+ back = null;
778
+ }
779
+ if (back !== before) {
780
+ try {
781
+ removeValue(backend, item);
782
+ }
783
+ catch { /* the file stays the copy that counts */ }
784
+ throw new SecretStoreError("A secret couldn't be moved to the keychain; it stays in its file until it can.");
785
+ }
786
+ }
787
+ destroyOwnFile(file);
788
+ }
789
+ /**
790
+ * A secret Toolroll holds for an integration, by `name` ("linear-key", "google-mail", "flow-hook.12"), for the
791
+ * Toolroll whose folder is `state`. `file` (inside `state`) is where it was kept before, and still is where there's no
792
+ * keychain. The first read or write with a keychain moves the file in.
793
+ */
794
+ export function readHeldSecret(state, name, file) {
795
+ const folder = secretFolder(state);
796
+ const path = pathIn(folder.root, file);
797
+ const backend = secretBackend();
798
+ if (backend === null)
799
+ return readOwnFile(path);
800
+ return withSecretLock(folder, `held.${name}`, () => {
801
+ moveHeldIn(backend, folder, name, path);
802
+ return readSealed(backend, folder, heldItem(folder, name));
803
+ });
804
+ }
805
+ /** Keep (or, with null, remove) a held secret: see readHeldSecret. */
806
+ export function writeHeldSecret(state, name, file, value) {
807
+ const folder = secretFolder(state);
808
+ const path = pathIn(folder.root, file, { make: true });
809
+ const backend = secretBackend();
810
+ if (backend === null) {
811
+ if (value === null)
812
+ destroyOwnFile(path);
813
+ else
814
+ writeOwnFile(path, value);
815
+ return;
816
+ }
817
+ withSecretLock(folder, `held.${name}`, () => {
818
+ moveHeldIn(backend, folder, name, path);
819
+ const item = heldItem(folder, name);
820
+ if (value === null)
821
+ removeValue(backend, item);
822
+ else
823
+ writeSealed(backend, folder, item, value);
824
+ });
825
+ }
826
+ /** Whether a held secret has a value, without reading it where that can be helped. */
827
+ export function heldSecretSet(state, name, file) {
828
+ try {
829
+ return readHeldSecret(state, name, file) !== null;
830
+ }
831
+ catch {
832
+ return false;
833
+ }
834
+ }