approval-md 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +98 -13
  3. package/cli.js +66 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +19 -0
  8. package/dist/src/channels/contract.js +105 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +271 -1
  22. package/dist/src/channels/telegram.js +403 -45
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/cli/amend.js +350 -4
  25. package/dist/src/cli/amend.js.map +1 -1
  26. package/dist/src/cli/attest.js +43 -6
  27. package/dist/src/cli/attest.js.map +1 -1
  28. package/dist/src/cli/channel-relay.d.ts +108 -0
  29. package/dist/src/cli/channel-relay.js +334 -0
  30. package/dist/src/cli/channel-relay.js.map +1 -0
  31. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  32. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  33. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  34. package/dist/src/cli/channel-telegram.d.ts +272 -26
  35. package/dist/src/cli/channel-telegram.js +478 -41
  36. package/dist/src/cli/channel-telegram.js.map +1 -1
  37. package/dist/src/cli/channel.d.ts +1 -1
  38. package/dist/src/cli/channel.js +5 -1
  39. package/dist/src/cli/channel.js.map +1 -1
  40. package/dist/src/cli/daemon.js +37 -5
  41. package/dist/src/cli/daemon.js.map +1 -1
  42. package/dist/src/cli/doctor.d.ts +63 -0
  43. package/dist/src/cli/doctor.js +856 -111
  44. package/dist/src/cli/doctor.js.map +1 -1
  45. package/dist/src/cli/execute.d.ts +17 -1
  46. package/dist/src/cli/execute.js +341 -20
  47. package/dist/src/cli/execute.js.map +1 -1
  48. package/dist/src/cli/exit-codes.d.ts +12 -0
  49. package/dist/src/cli/exit-codes.js +13 -0
  50. package/dist/src/cli/exit-codes.js.map +1 -1
  51. package/dist/src/cli/gate.d.ts +2 -0
  52. package/dist/src/cli/gate.js +233 -2
  53. package/dist/src/cli/gate.js.map +1 -1
  54. package/dist/src/cli/help.d.ts +9 -1
  55. package/dist/src/cli/help.js +246 -38
  56. package/dist/src/cli/help.js.map +1 -1
  57. package/dist/src/cli/hook.d.ts +371 -3
  58. package/dist/src/cli/hook.js +1926 -44
  59. package/dist/src/cli/hook.js.map +1 -1
  60. package/dist/src/cli/init.js +16 -7
  61. package/dist/src/cli/init.js.map +1 -1
  62. package/dist/src/cli/instructions.js +24 -3
  63. package/dist/src/cli/instructions.js.map +1 -1
  64. package/dist/src/cli/log-anchor.d.ts +55 -0
  65. package/dist/src/cli/log-anchor.js +199 -60
  66. package/dist/src/cli/log-anchor.js.map +1 -1
  67. package/dist/src/cli/main.d.ts +0 -12
  68. package/dist/src/cli/main.js +51 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/muse.d.ts +2 -0
  71. package/dist/src/cli/muse.js +62 -0
  72. package/dist/src/cli/muse.js.map +1 -0
  73. package/dist/src/cli/payload.js +79 -1
  74. package/dist/src/cli/payload.js.map +1 -1
  75. package/dist/src/cli/scaffold.js +6 -0
  76. package/dist/src/cli/scaffold.js.map +1 -1
  77. package/dist/src/cli/serve.d.ts +53 -0
  78. package/dist/src/cli/serve.js +270 -0
  79. package/dist/src/cli/serve.js.map +1 -0
  80. package/dist/src/cli/up.js +21 -2
  81. package/dist/src/cli/up.js.map +1 -1
  82. package/dist/src/cli/verb-registry.d.ts +2 -0
  83. package/dist/src/cli/verb-registry.js +337 -39
  84. package/dist/src/cli/verb-registry.js.map +1 -1
  85. package/dist/src/cli/wordmark.d.ts +2 -2
  86. package/dist/src/cli/wordmark.js +2 -2
  87. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  88. package/dist/src/core/attest-bootstrap.js +135 -0
  89. package/dist/src/core/attest-bootstrap.js.map +1 -0
  90. package/dist/src/core/attest.d.ts +20 -3
  91. package/dist/src/core/attest.js +25 -0
  92. package/dist/src/core/attest.js.map +1 -1
  93. package/dist/src/core/audit.d.ts +55 -3
  94. package/dist/src/core/audit.js +52 -5
  95. package/dist/src/core/audit.js.map +1 -1
  96. package/dist/src/core/channel-lease.d.ts +322 -0
  97. package/dist/src/core/channel-lease.js +663 -0
  98. package/dist/src/core/channel-lease.js.map +1 -0
  99. package/dist/src/core/command-class.d.ts +51 -0
  100. package/dist/src/core/command-class.js +760 -8
  101. package/dist/src/core/command-class.js.map +1 -1
  102. package/dist/src/core/daemon-host.d.ts +178 -0
  103. package/dist/src/core/daemon-host.js +228 -0
  104. package/dist/src/core/daemon-host.js.map +1 -0
  105. package/dist/src/core/daemon-identity.d.ts +238 -0
  106. package/dist/src/core/daemon-identity.js +252 -0
  107. package/dist/src/core/daemon-identity.js.map +1 -0
  108. package/dist/src/core/gate.d.ts +306 -5
  109. package/dist/src/core/gate.js +772 -24
  110. package/dist/src/core/gate.js.map +1 -1
  111. package/dist/src/core/harness-version.d.ts +128 -4
  112. package/dist/src/core/harness-version.js +179 -12
  113. package/dist/src/core/harness-version.js.map +1 -1
  114. package/dist/src/core/harness-wait.d.ts +82 -3
  115. package/dist/src/core/harness-wait.js +89 -3
  116. package/dist/src/core/harness-wait.js.map +1 -1
  117. package/dist/src/core/log-subscribe.d.ts +21 -0
  118. package/dist/src/core/log-subscribe.js +16 -6
  119. package/dist/src/core/log-subscribe.js.map +1 -1
  120. package/dist/src/core/log-write-layer.d.ts +55 -0
  121. package/dist/src/core/log-write-layer.js +91 -0
  122. package/dist/src/core/log-write-layer.js.map +1 -0
  123. package/dist/src/core/log.d.ts +50 -21
  124. package/dist/src/core/log.js +197 -19
  125. package/dist/src/core/log.js.map +1 -1
  126. package/dist/src/core/payload-census.d.ts +51 -0
  127. package/dist/src/core/payload-census.js +125 -2
  128. package/dist/src/core/payload-census.js.map +1 -1
  129. package/dist/src/core/payload-store.d.ts +31 -0
  130. package/dist/src/core/payload-store.js +113 -8
  131. package/dist/src/core/payload-store.js.map +1 -1
  132. package/dist/src/core/payload.d.ts +18 -3
  133. package/dist/src/core/payload.js +20 -4
  134. package/dist/src/core/payload.js.map +1 -1
  135. package/dist/src/core/policy-diff.d.ts +10 -0
  136. package/dist/src/core/policy-diff.js +10 -2
  137. package/dist/src/core/policy-diff.js.map +1 -1
  138. package/dist/src/core/policy-explain.d.ts +7 -1
  139. package/dist/src/core/policy-explain.js +16 -1
  140. package/dist/src/core/policy-explain.js.map +1 -1
  141. package/dist/src/core/policy-load.d.ts +24 -0
  142. package/dist/src/core/policy-load.js.map +1 -1
  143. package/dist/src/core/policy-match.d.ts +38 -0
  144. package/dist/src/core/policy-match.js +35 -0
  145. package/dist/src/core/policy-match.js.map +1 -1
  146. package/dist/src/core/policy-proposal.d.ts +23 -0
  147. package/dist/src/core/policy-proposal.js +8 -2
  148. package/dist/src/core/policy-proposal.js.map +1 -1
  149. package/dist/src/core/read-scope.d.ts +10 -0
  150. package/dist/src/core/read-scope.js +4 -0
  151. package/dist/src/core/read-scope.js.map +1 -1
  152. package/dist/src/core/run-payload.d.ts +160 -0
  153. package/dist/src/core/run-payload.js +304 -0
  154. package/dist/src/core/run-payload.js.map +1 -0
  155. package/dist/src/core/sender-identity.d.ts +35 -10
  156. package/dist/src/core/sender-identity.js +48 -11
  157. package/dist/src/core/sender-identity.js.map +1 -1
  158. package/dist/src/core/state.d.ts +52 -1
  159. package/dist/src/core/state.js +52 -6
  160. package/dist/src/core/state.js.map +1 -1
  161. package/dist/src/core/telegram-config.d.ts +79 -0
  162. package/dist/src/core/telegram-config.js +108 -0
  163. package/dist/src/core/telegram-config.js.map +1 -1
  164. package/dist/src/core/verify.d.ts +23 -1
  165. package/dist/src/core/verify.js +20 -2
  166. package/dist/src/core/verify.js.map +1 -1
  167. package/dist/src/daemon/audit.d.ts +80 -5
  168. package/dist/src/daemon/audit.js +126 -6
  169. package/dist/src/daemon/audit.js.map +1 -1
  170. package/dist/src/daemon/daemon.d.ts +174 -2
  171. package/dist/src/daemon/daemon.js +298 -7
  172. package/dist/src/daemon/daemon.js.map +1 -1
  173. package/dist/src/daemon/git-evidence.d.ts +2 -2
  174. package/dist/src/daemon/git-evidence.js +1 -1
  175. package/dist/src/daemon/projection.d.ts +13 -2
  176. package/dist/src/daemon/projection.js +39 -6
  177. package/dist/src/daemon/projection.js.map +1 -1
  178. package/dist/src/mcp/server.d.ts +39 -3
  179. package/dist/src/mcp/server.js +22 -3
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/dist/src/muse/server.d.ts +16 -0
  182. package/dist/src/muse/server.js +238 -0
  183. package/dist/src/muse/server.js.map +1 -0
  184. package/dist/src/serve/archive.d.ts +158 -0
  185. package/dist/src/serve/archive.js +354 -0
  186. package/dist/src/serve/archive.js.map +1 -0
  187. package/dist/src/serve/arguments.d.ts +93 -0
  188. package/dist/src/serve/arguments.js +228 -0
  189. package/dist/src/serve/arguments.js.map +1 -0
  190. package/dist/src/serve/credentials.d.ts +91 -0
  191. package/dist/src/serve/credentials.js +143 -0
  192. package/dist/src/serve/credentials.js.map +1 -0
  193. package/dist/src/serve/follow.d.ts +86 -0
  194. package/dist/src/serve/follow.js +101 -0
  195. package/dist/src/serve/follow.js.map +1 -0
  196. package/dist/src/serve/hook-thread.d.ts +134 -0
  197. package/dist/src/serve/hook-thread.js +333 -0
  198. package/dist/src/serve/hook-thread.js.map +1 -0
  199. package/dist/src/serve/hook-worker.d.ts +134 -0
  200. package/dist/src/serve/hook-worker.js +173 -0
  201. package/dist/src/serve/hook-worker.js.map +1 -0
  202. package/dist/src/serve/server.d.ts +412 -0
  203. package/dist/src/serve/server.js +1251 -0
  204. package/dist/src/serve/server.js.map +1 -0
  205. package/docs/README-extended.md +766 -0
  206. package/docs/cli-reference.md +1633 -43
  207. package/package.json +4 -2
  208. package/schema/event.schema.json +59 -2
  209. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  210. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  211. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  212. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  213. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  214. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  215. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  216. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  217. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  218. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  219. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  220. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  221. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  222. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  223. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  224. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  225. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  226. package/schema/policy.schema.json +50 -6
@@ -0,0 +1,663 @@
1
+ /**
2
+ * One transport per gate, enforced locally (APRV-424, review finding 1).
3
+ *
4
+ * `core/channel-owner.ts` answers "does another INSTANCE on this machine hold
5
+ * this bot", keyed on the instance id, and `getWebhookInfo` answers "what is
6
+ * the Bot API delivering this bot's updates to". Between those two answers
7
+ * there was a hole, and it is the one this file closes: two processes started
8
+ * in the SAME gate, one `approval up` long-polling and one `approval channel
9
+ * telegram webhook`, pass both checks. The registry compares instance ids and
10
+ * both processes are the same instance; `getWebhookInfo` is empty when the
11
+ * poller is the one already running. Each then runs its own dispatch cycle
12
+ * over its own `DispatchState` against one log, so every pending request
13
+ * reaches the phone twice under two nonces and only one of the two copies can
14
+ * resolve a tap.
15
+ *
16
+ * ## What the lease is
17
+ *
18
+ * A lockfile in the gate's own derived state directory:
19
+ *
20
+ * ```
21
+ * <instance home>/.approval/daemon/telegram-transport.lock
22
+ * ```
23
+ *
24
+ * `daemon/` because that is where this runtime already keeps per-gate runtime
25
+ * state that a clone must not inherit (`core/live-draw.ts`'s socket lives
26
+ * beside it, and `.gitignore` holds the whole directory). It carries the
27
+ * holder's pid, which transport it is running, and when it started, and it is
28
+ * created with `O_EXCL` so two processes racing cannot both believe they took
29
+ * it.
30
+ *
31
+ * ## Per PROCESS, and validated for liveness
32
+ *
33
+ * The holder is a pid, so:
34
+ *
35
+ * - **A second process refuses.** It reads the holder, sees a live pid, and is
36
+ * told which transport is running and under which pid. The refusal is the
37
+ * whole point: a channel that ran both transports would deliver twice.
38
+ * - **A dead holder is reclaimed.** A crash, a `kill -9`, a laptop that slept
39
+ * through a power cycle: the lockfile outlives the process, and refusing
40
+ * forever over a file nobody owns would be an outage with no fault behind
41
+ * it. `process.kill(pid, 0)` is the probe; `EPERM` counts as alive, because
42
+ * a pid that belongs to somebody else is still a pid in use.
43
+ * - **The same pid is never in conflict with itself.** One process holds one
44
+ * transport, and the in-process half of that rule is
45
+ * `TelegramChannel.claimTransport`, which refuses a second claim on one
46
+ * channel object. A re-take by the holding pid rewrites the record rather
47
+ * than refusing, so a supervisor that restarts its own channel part inside
48
+ * one process (`approval up`) is not fighting its own lockfile.
49
+ *
50
+ * A recycled pid is the acknowledged limit: a long-dead holder whose number
51
+ * has been reused by an unrelated program reads as alive, and the operator
52
+ * gets a refusal naming a pid that is not a gate. That is the safe direction
53
+ * of the error (a refusal an operator can clear by deleting a named file,
54
+ * rather than two pollers on one log), and it is the same trade
55
+ * `core/live-draw.ts` makes when it validates the answering daemon's pid.
56
+ *
57
+ * ## Why an I/O failure is a refusal
58
+ *
59
+ * Every other local record in this runtime is a cache whose absence is the
60
+ * status quo (`channel-owner.ts` says so at length). This one is not: it is
61
+ * the only thing standing between a gate and two live transports, so a
62
+ * directory it cannot create or a file it cannot write is a state in which
63
+ * exclusion cannot be promised, and the stricter path is to say so and stop.
64
+ * The refusal names the path, which is a thing an operator can fix.
65
+ *
66
+ * Nothing here is evidence. It is never read by an enforcement path, it
67
+ * widens no permission, and the only thing it can produce is a refusal to
68
+ * start.
69
+ */
70
+ import { spawnSync } from "node:child_process";
71
+ import { randomBytes } from "node:crypto";
72
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
73
+ import { join, resolve } from "node:path";
74
+ import { instanceHomeFor } from "./instance.js";
75
+ /** The lockfile's basename inside the gate's `daemon/` directory. */
76
+ export const CHANNEL_LEASE_FILE = "telegram-transport.lock";
77
+ /** The only format version this build writes, and the only one it reads. */
78
+ export const CHANNEL_LEASE_VERSION = 1;
79
+ /** How many times a take retries around a torn or reclaimed lockfile. */
80
+ export const CHANNEL_LEASE_ATTEMPTS = 5;
81
+ /** How long a take waits before re-reading a lockfile it could not parse. */
82
+ export const CHANNEL_LEASE_RETRY_MS = 20;
83
+ /**
84
+ * The sibling file that serializes TAKE-OVERS of an existing lockfile.
85
+ *
86
+ * The lease itself is taken with `O_EXCL`, which settles the ordinary race by
87
+ * itself: exactly one of two processes creating a file that is not there wins.
88
+ * Reclaiming a lockfile that IS there cannot be settled that way, and the
89
+ * first version of this module proved it. Two processes both read the same
90
+ * dead holder, the first unlinked it and created its own live lease, and the
91
+ * second, resuming inside its own read-then-unlink, deleted that live lease
92
+ * and created its own. Both then believed they held the gate, which is the
93
+ * exact state this file exists to rule out.
94
+ *
95
+ * So a take-over happens inside a critical section of its own, entered with
96
+ * the same `O_EXCL` primitive, and what happens inside it is a re-read: the
97
+ * record must still be the very one that was judged dead (same pid, same
98
+ * `started_at`) or the take-over is abandoned. The loser of that race finds a
99
+ * LIVE record on its next pass and refuses, which is the right answer.
100
+ */
101
+ export const CHANNEL_LEASE_RECLAIM_SUFFIX = ".reclaim";
102
+ /**
103
+ * How old a reclaim lock has to be before its age is worth saying out loud.
104
+ *
105
+ * It DECIDES NOTHING, and that is the third review's first finding. An earlier
106
+ * version evicted a reclaim lock on age alone, which handed the section to a
107
+ * second process while the first was still inside it: the first then renamed
108
+ * its lease over the second's and deleted the second's lock on its way out.
109
+ * What decides an eviction now is the same liveness probe a lease holder is
110
+ * judged by, because "five seconds have passed" says nothing about whether
111
+ * anybody is still in the room.
112
+ */
113
+ export const CHANNEL_LEASE_RECLAIM_STALE_MS = 5_000;
114
+ /**
115
+ * How much later than the lease a process may have started and still be its
116
+ * holder.
117
+ *
118
+ * `started_at` is written just after the holder is running, so the holder's
119
+ * own start time is at or before it. A pid whose process started AFTER the
120
+ * lease was written is a pid the operating system handed to somebody else,
121
+ * which is the case this tolerance exists to detect rather than to forgive.
122
+ * The slack covers the coarse clock the fallback probe reports (`ps -o
123
+ * lstart=` has one-second resolution) and small clock adjustments.
124
+ */
125
+ export const CHANNEL_LEASE_START_TOLERANCE_MS = 2_000;
126
+ /**
127
+ * Why a lease could not be taken. Frozen, per SPEC.md §11.1 invariant 6.
128
+ *
129
+ * Two of the three name the transport that already holds the gate, because
130
+ * that is what decides the repair: stop the poller, or stop the webhook runner
131
+ * (which also removes the registration as it exits). The third is the state in
132
+ * which no promise can be made at all.
133
+ */
134
+ export const CHANNEL_LEASE_REFUSAL_CODES = [
135
+ /** A long-poll listener (`approval channel telegram listen`, `approval up`) holds it. */
136
+ "telegram-poller-running",
137
+ /** A webhook runner holds it. Same code the Bot API probe uses, same repair. */
138
+ "webhook-registered",
139
+ /** The lockfile could not be created, read or written, so exclusion cannot be promised. */
140
+ "telegram-lease-unavailable",
141
+ ];
142
+ /** The gate's `daemon/` directory for `logPath`, derived and never configured. */
143
+ export function channelLeaseDirFor(logPath) {
144
+ return join(instanceHomeFor(resolve(logPath)), "daemon");
145
+ }
146
+ /** Where the Telegram transport lease for `logPath` lives. */
147
+ export function channelLeasePathFor(logPath) {
148
+ return join(channelLeaseDirFor(logPath), CHANNEL_LEASE_FILE);
149
+ }
150
+ /**
151
+ * When the process with this pid started, or `null` when it cannot be learned.
152
+ *
153
+ * Two sources, in order of cost. On Linux `/proc/<pid>` is created with the
154
+ * process and carries its start time, which needs no subprocess. Everywhere
155
+ * else `ps -o lstart=` answers the same question; it costs one short-lived
156
+ * child, paid once per lease take (which happens once per process start), and
157
+ * a `ps` that is missing, slow or unhelpful returns `null` rather than
158
+ * failing.
159
+ *
160
+ * `null` is the fail-CLOSED direction here: a holder whose start time cannot
161
+ * be compared is judged by its pid alone, exactly as before this probe
162
+ * existed, and a live pid then still refuses.
163
+ */
164
+ export function processStartedAt(pid) {
165
+ if (!Number.isInteger(pid) || pid <= 0)
166
+ return null;
167
+ if (process.platform === "linux") {
168
+ try {
169
+ return statSync(`/proc/${String(pid)}`).mtime;
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ }
175
+ try {
176
+ const run = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
177
+ encoding: "utf8",
178
+ timeout: 2_000,
179
+ windowsHide: true,
180
+ });
181
+ if (run.status !== 0)
182
+ return null;
183
+ const text = (run.stdout ?? "").trim();
184
+ if (text.length === 0)
185
+ return null;
186
+ const when = new Date(text);
187
+ return Number.isNaN(when.getTime()) ? null : when;
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ }
193
+ /** Ask the operating system about one pid. The default {@link ChannelLeaseOptions.probe}. */
194
+ export function probePid(pid) {
195
+ if (!Number.isInteger(pid) || pid <= 0) {
196
+ return { running: false, foreign: false, startedAt: null };
197
+ }
198
+ try {
199
+ process.kill(pid, 0);
200
+ }
201
+ catch (cause) {
202
+ const code = cause.code;
203
+ if (code === "ESRCH")
204
+ return { running: false, foreign: false, startedAt: null };
205
+ if (code === "EPERM")
206
+ return { running: true, foreign: true, startedAt: processStartedAt(pid) };
207
+ // Anything else is unexpected, and the stricter reading of an unexpected
208
+ // answer is that the holder is there.
209
+ return { running: true, foreign: false, startedAt: processStartedAt(pid) };
210
+ }
211
+ return { running: true, foreign: false, startedAt: processStartedAt(pid) };
212
+ }
213
+ /**
214
+ * Is `pid` a running process?
215
+ *
216
+ * Kept for the callers that only want the coarse answer. The lease itself asks
217
+ * {@link probePid}, because a pid alone cannot tell a holder from a number the
218
+ * operating system has since handed to somebody else.
219
+ */
220
+ export function pidIsAlive(pid) {
221
+ return probePid(pid).running;
222
+ }
223
+ /**
224
+ * Why a lease stopped being its holder's.
225
+ *
226
+ * Three ways, and the operator is told which: the process is gone, the number
227
+ * now belongs to somebody this process cannot signal, or the number is in use
228
+ * by a process that started after the lease was written and therefore cannot
229
+ * be the one that wrote it.
230
+ */
231
+ export const CHANNEL_LEASE_RECLAIM_REASONS = ["gone", "foreign", "recycled"];
232
+ /** Is this holder still the process that wrote the lease? */
233
+ function judgeHolder(holder, probe) {
234
+ const found = probe(holder.pid);
235
+ if (!found.running)
236
+ return { held: false, reason: "gone" };
237
+ if (found.foreign)
238
+ return { held: false, reason: "foreign" };
239
+ if (found.startedAt !== null) {
240
+ const wrote = Date.parse(holder.startedAt);
241
+ if (!Number.isNaN(wrote) &&
242
+ found.startedAt.getTime() > wrote + CHANNEL_LEASE_START_TOLERANCE_MS) {
243
+ return { held: false, reason: "recycled" };
244
+ }
245
+ }
246
+ return { held: true };
247
+ }
248
+ /** How a reclaim is reported to the operator. */
249
+ export function reclaimDetail(holder, reason) {
250
+ const why = reason === "gone"
251
+ ? "that process is gone"
252
+ : reason === "foreign"
253
+ ? "that pid now belongs to a process this one cannot signal, so it is not the holder"
254
+ : "that pid belongs to a process that started after the lease was written, so the number has been reused";
255
+ return `the telegram transport lease was held by pid ${String(holder.pid)} in ${holder.mode} mode since ${holder.startedAt}, and ${why}; reclaimed`;
256
+ }
257
+ /** Synchronous sleep with no dependency and no busy-spin. `core/log.ts`'s. */
258
+ function sleepSync(ms) {
259
+ const buffer = new Int32Array(new SharedArrayBuffer(4));
260
+ Atomics.wait(buffer, 0, 0, ms);
261
+ }
262
+ function parseHolder(raw) {
263
+ if (typeof raw !== "object" || raw === null)
264
+ return null;
265
+ const record = raw;
266
+ if (record["version"] !== CHANNEL_LEASE_VERSION)
267
+ return null;
268
+ const pid = record["pid"];
269
+ const mode = record["mode"];
270
+ const startedAt = record["started_at"];
271
+ const instanceHome = record["instance_home"];
272
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
273
+ return null;
274
+ if (mode !== "poll" && mode !== "webhook")
275
+ return null;
276
+ if (typeof startedAt !== "string" || startedAt.length === 0)
277
+ return null;
278
+ if (typeof instanceHome !== "string")
279
+ return null;
280
+ return { pid, mode, startedAt, instanceHome };
281
+ }
282
+ function readHolder(path) {
283
+ let raw;
284
+ try {
285
+ raw = readFileSync(path, "utf8");
286
+ }
287
+ catch (cause) {
288
+ return cause.code === "ENOENT" ? { state: "absent" } : { state: "torn" };
289
+ }
290
+ let parsed;
291
+ try {
292
+ parsed = JSON.parse(raw);
293
+ }
294
+ catch {
295
+ return { state: "torn" };
296
+ }
297
+ const holder = parseHolder(parsed);
298
+ return holder === null ? { state: "torn" } : { state: "held", holder };
299
+ }
300
+ /**
301
+ * {@link readHolder}, re-read while it comes back unreadable.
302
+ *
303
+ * A record this build cannot parse is either a lockfile a crash tore in half
304
+ * or one a process is writing right now, and for as long as that write takes
305
+ * the two look alike. So an unreadable file is re-read before it is judged,
306
+ * and only one that stays unreadable is reported as torn.
307
+ */
308
+ function readHolderSettled(path) {
309
+ let read = readHolder(path);
310
+ for (let attempt = 1; read.state === "torn" && attempt < CHANNEL_LEASE_ATTEMPTS; attempt += 1) {
311
+ sleepSync(CHANNEL_LEASE_RETRY_MS);
312
+ read = readHolder(path);
313
+ }
314
+ return read;
315
+ }
316
+ /** What the lockfile for `logPath` currently says, or `null`. Diagnostics only. */
317
+ export function readChannelLease(logPath) {
318
+ const read = readHolder(channelLeasePathFor(logPath));
319
+ return read.state === "held" ? read.holder : null;
320
+ }
321
+ /** The code a refusal takes, given which transport holds the gate. */
322
+ function codeFor(mode) {
323
+ return mode === "webhook" ? "webhook-registered" : "telegram-poller-running";
324
+ }
325
+ function refusalMessage(holder, wanted, path) {
326
+ const held = holder.mode === "webhook"
327
+ ? `a webhook runner (\`approval channel telegram webhook\`) is already receiving this gate's telegram updates`
328
+ : `a long-poll listener (\`approval channel telegram listen\` or \`approval up\`) is already receiving this gate's telegram updates`;
329
+ const same = holder.mode === wanted;
330
+ const why = same
331
+ ? "Two of them on one log both put every pending request on the phone, each under its own nonce, and only the copy whose process holds the delivery can resolve a tap"
332
+ : "Long polling and a webhook are alternatives per bot: both processes would put every pending request on the phone under their own nonce, only one of the two copies could resolve a tap, and the Bot API refuses getUpdates outright while a webhook is set";
333
+ return `${held} in ${holder.mode} mode, as pid ${String(holder.pid)}, since ${holder.startedAt}. ${why}. Stop that process (a clean stop removes its lease, and a webhook runner removes its registration as it exits), or give this instance its own bot with \`approval setup channel telegram\`. The lease is ${path}; a lease whose process is gone is reclaimed automatically, so deleting it by hand is only for a pid that no longer names a gate`;
334
+ }
335
+ /**
336
+ * Take the gate's Telegram transport lease in `mode`, or say who holds it.
337
+ *
338
+ * Synchronous, like every other lockfile in this runtime: the take happens
339
+ * once per process before anything is delivered, and a caller that had to
340
+ * await it would be a caller that could interleave two takes in one process.
341
+ */
342
+ export function takeChannelLease(logPath, mode, options = {}) {
343
+ const pid = options.pid ?? process.pid;
344
+ const now = options.now ?? (() => new Date());
345
+ const coarse = options.alive;
346
+ const probe = options.probe ??
347
+ (coarse === undefined
348
+ ? probePid
349
+ : (candidate) => ({
350
+ running: coarse(candidate),
351
+ foreign: false,
352
+ startedAt: null,
353
+ }));
354
+ const path = channelLeasePathFor(logPath);
355
+ const instanceHome = instanceHomeFor(resolve(logPath));
356
+ const unavailable = (detail) => ({
357
+ ok: false,
358
+ code: "telegram-lease-unavailable",
359
+ holder: null,
360
+ message: `the telegram transport lease ${path} could not be taken (${detail}). This lease is the only thing that keeps one gate from running a poller and a webhook at once, so a gate that cannot hold one does not start: make that directory writable, or point --log at the instance this process is meant to serve`,
361
+ });
362
+ try {
363
+ mkdirSync(channelLeaseDirFor(logPath), { recursive: true, mode: 0o700 });
364
+ }
365
+ catch (cause) {
366
+ return unavailable(cause instanceof Error ? cause.message : String(cause));
367
+ }
368
+ let reclaimed = null;
369
+ let reclaimedBecause = null;
370
+ for (let attempt = 0; attempt < CHANNEL_LEASE_ATTEMPTS; attempt += 1) {
371
+ const body = `${JSON.stringify({
372
+ version: CHANNEL_LEASE_VERSION,
373
+ pid,
374
+ mode,
375
+ started_at: now().toISOString(),
376
+ instance_home: instanceHome,
377
+ }, null, 2)}\n`;
378
+ // The ordinary case, and the only one `O_EXCL` can settle on its own:
379
+ // nothing holds the gate, and exactly one of any number of processes
380
+ // creating this file wins.
381
+ const created = createExclusive(path, body);
382
+ if (created === "created") {
383
+ return { ok: true, lease: leaseFor(path, mode, pid), reclaimed, reclaimedBecause };
384
+ }
385
+ if (created !== "exists")
386
+ return unavailable(created.detail);
387
+ const read = readHolderSettled(path);
388
+ if (read.state === "absent")
389
+ continue;
390
+ if (read.state === "held" && read.holder.pid === pid) {
391
+ // This process already holds the gate. One process runs one transport,
392
+ // and the in-process guard against two is `claimTransport`; rewriting
393
+ // the record keeps it true rather than making a supervisor fight its own
394
+ // lockfile across a restart of its channel part. Written through a temp
395
+ // and a rename, so a reader never sees half a record.
396
+ const rewritten = writeAtomic(path, body, pid);
397
+ if (rewritten !== null)
398
+ return unavailable(rewritten);
399
+ return { ok: true, lease: leaseFor(path, mode, pid), reclaimed, reclaimedBecause };
400
+ }
401
+ if (read.state === "held") {
402
+ const verdict = judgeHolder(read.holder, probe);
403
+ if (verdict.held) {
404
+ return {
405
+ ok: false,
406
+ code: codeFor(read.holder.mode),
407
+ holder: read.holder,
408
+ message: refusalMessage(read.holder, mode, path),
409
+ };
410
+ }
411
+ options.beforeTakeOver?.();
412
+ const taken = takeOver(path, body, pid, { kind: "holder", holder: read.holder }, probe, {
413
+ ...(options.note === undefined ? {} : { note: options.note }),
414
+ ...(options.insideTakeOver === undefined ? {} : { inside: options.insideTakeOver }),
415
+ });
416
+ if (taken === "taken") {
417
+ reclaimed = read.holder;
418
+ reclaimedBecause = verdict.reason;
419
+ return { ok: true, lease: leaseFor(path, mode, pid), reclaimed, reclaimedBecause };
420
+ }
421
+ if (taken === "changed" || taken === "busy") {
422
+ sleepSync(CHANNEL_LEASE_RETRY_MS);
423
+ continue;
424
+ }
425
+ return unavailable(taken.detail);
426
+ }
427
+ // Unreadable after {@link CHANNEL_LEASE_ATTEMPTS} reads: nobody is
428
+ // finishing a write, so this is a lockfile a crash tore in half, and
429
+ // refusing forever over bytes nobody can read would be an outage with no
430
+ // fault behind it. Replaced rather than deleted-then-created, so the path
431
+ // is never absent and no third process can slip in through the gap.
432
+ const replaced = takeOver(path, body, pid, { kind: "torn" }, probe, {
433
+ ...(options.note === undefined ? {} : { note: options.note }),
434
+ ...(options.insideTakeOver === undefined ? {} : { inside: options.insideTakeOver }),
435
+ });
436
+ if (replaced === "taken") {
437
+ return { ok: true, lease: leaseFor(path, mode, pid), reclaimed, reclaimedBecause };
438
+ }
439
+ if (replaced === "changed" || replaced === "busy") {
440
+ sleepSync(CHANNEL_LEASE_RETRY_MS);
441
+ continue;
442
+ }
443
+ return unavailable(replaced.detail);
444
+ }
445
+ return unavailable(`${String(CHANNEL_LEASE_ATTEMPTS)} attempts raced another process taking it over. Something else is reclaiming this gate's transport lease and has not finished: ${path}${CHANNEL_LEASE_RECLAIM_SUFFIX} is that take-over in progress. This process refuses rather than force it, because forcing a critical section is how two transports end up running at once; if that file has no process behind it and this keeps happening, remove it`);
446
+ }
447
+ /** `O_EXCL` create, the one race the file system settles by itself. */
448
+ function createExclusive(path, body) {
449
+ try {
450
+ const handle = openSync(path, "wx", 0o600);
451
+ try {
452
+ writeFileSync(handle, body, { encoding: "utf8" });
453
+ }
454
+ finally {
455
+ closeSync(handle);
456
+ }
457
+ return "created";
458
+ }
459
+ catch (cause) {
460
+ if (cause.code === "EEXIST")
461
+ return "exists";
462
+ return { detail: cause instanceof Error ? cause.message : String(cause) };
463
+ }
464
+ }
465
+ /**
466
+ * Write `path` whole, by rename, never leaving it absent or half-written.
467
+ *
468
+ * `rename` replaces the destination in one step, so a reader either sees the
469
+ * old record or the new one. Returns `null` on success and the detail of the
470
+ * failure otherwise.
471
+ */
472
+ function writeAtomic(path, body, pid) {
473
+ temporaryCounter += 1;
474
+ const temporary = `${path}.${String(pid)}.${String(temporaryCounter)}.${String(Date.now())}.tmp`;
475
+ try {
476
+ writeFileSync(temporary, body, { encoding: "utf8", mode: 0o600 });
477
+ renameSync(temporary, path);
478
+ return null;
479
+ }
480
+ catch (cause) {
481
+ try {
482
+ unlinkSync(temporary);
483
+ }
484
+ catch {
485
+ /* the temp may never have been created; its absence is the desired state */
486
+ }
487
+ return cause instanceof Error ? cause.message : String(cause);
488
+ }
489
+ }
490
+ let temporaryCounter = 0;
491
+ function readReclaimLock(lockPath) {
492
+ let raw;
493
+ try {
494
+ raw = readFileSync(lockPath, "utf8");
495
+ }
496
+ catch {
497
+ return null;
498
+ }
499
+ let parsed;
500
+ try {
501
+ parsed = JSON.parse(raw);
502
+ }
503
+ catch {
504
+ return null;
505
+ }
506
+ if (typeof parsed !== "object" || parsed === null)
507
+ return null;
508
+ const record = parsed;
509
+ const pid = record["pid"];
510
+ const nonce = record["nonce"];
511
+ const at = record["at"];
512
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
513
+ return null;
514
+ if (typeof nonce !== "string" || nonce.length === 0)
515
+ return null;
516
+ return { pid, nonce, at: typeof at === "number" ? at : 0 };
517
+ }
518
+ /** Does the lock on disk still name this entry? */
519
+ function holdsSection(lockPath, pid, nonce) {
520
+ const lock = readReclaimLock(lockPath);
521
+ return lock !== null && lock.pid === pid && lock.nonce === nonce;
522
+ }
523
+ /**
524
+ * Replace a lockfile somebody else wrote, inside a critical section.
525
+ *
526
+ * The section is the whole of the fix for the interleaving in APRV-424's
527
+ * second review: two processes that had both read one dead holder used to take
528
+ * turns deleting each other's live lease. Inside it the record is read AGAIN
529
+ * and must still be exactly what was judged (the same pid and the same
530
+ * `started_at`), so the second reclaimer of a pair abandons its take-over,
531
+ * finds a live record on the next pass, and refuses.
532
+ *
533
+ * ## The section is owned, not merely occupied (third review, finding 1)
534
+ *
535
+ * Holding the section is not enough; a process has to still be holding it at
536
+ * the moment it acts. The first version aged a reclaim lock out after five
537
+ * seconds and unlinked it on the way out unconditionally, which gave the
538
+ * section away and then took somebody else's:
539
+ *
540
+ * A enters, re-reads, and stalls. B ages A's lock out, enters, reclaims, and
541
+ * renames its own lease in. A resumes and renames over B's live lease, and
542
+ * A's `finally` deletes B's reclaim lock for good measure. Two holders, again.
543
+ *
544
+ * Three changes close it, and all three are about identity:
545
+ *
546
+ * - The lock carries this process's pid AND a nonce, so one entry can be told
547
+ * from another by the same process.
548
+ * - The ownership is re-checked immediately before the rename. A process that
549
+ * lost the section abandons the take-over (`busy`) and meets the new
550
+ * holder's live record on its next pass.
551
+ * - The `finally` removes the lock only while it still names this entry.
552
+ *
553
+ * And the age-out is no longer a decision: a reclaim lock is evicted only when
554
+ * its own process is GONE, judged by the same probe a lease holder is judged
555
+ * by. The age is reported and nothing more, because "five seconds have passed"
556
+ * says nothing about whether anybody is still in the room.
557
+ *
558
+ * `busy` means another process holds the section right now: the caller waits
559
+ * and comes round again rather than forcing it, because forcing a critical
560
+ * section is the same as not having one.
561
+ */
562
+ function takeOver(path, body, pid, expect, probe, options = {}) {
563
+ const lockPath = `${path}${CHANNEL_LEASE_RECLAIM_SUFFIX}`;
564
+ const nonce = randomBytes(8).toString("hex");
565
+ const entered = createExclusive(lockPath, `${JSON.stringify({ pid, nonce, at: Date.now() })}\n`);
566
+ if (entered !== "created") {
567
+ if (entered !== "exists")
568
+ return entered;
569
+ const held = readReclaimLock(lockPath);
570
+ if (held === null) {
571
+ // Unreadable or half written: it may be a lock being entered right now,
572
+ // and it names no process, so there is no liveness question to ask about
573
+ // it. Age may not answer one either (see the constant above), so waiting
574
+ // is the only safe answer; the attempt budget bounds it and the refusal
575
+ // names the file.
576
+ return "busy";
577
+ }
578
+ if (probe(held.pid).running)
579
+ return "busy";
580
+ // Its process is gone, so it died inside the section. The age says how
581
+ // long ago that was and decides nothing: a lock five seconds old whose
582
+ // process is alive is a slow reclaim, and evicting it is how two holders
583
+ // happen.
584
+ const age = Date.now() - held.at;
585
+ options.note?.(`a telegram transport reclaim lock left by pid ${String(held.pid)} ${String(Math.max(age, 0))}ms ago has no process behind it; evicted`);
586
+ try {
587
+ unlinkSync(lockPath);
588
+ }
589
+ catch {
590
+ /* somebody else cleared it first, which is the same outcome */
591
+ }
592
+ return "busy";
593
+ }
594
+ try {
595
+ const fresh = readHolderSettled(path);
596
+ if (fresh.state === "absent") {
597
+ // The record went away while this process was entering the section: the
598
+ // holder released it, or another take-over finished. Create rather than
599
+ // rename, so a fresh taker that got there first is not overwritten.
600
+ const created = createExclusive(path, body);
601
+ if (created === "created")
602
+ return "taken";
603
+ return created === "exists" ? "changed" : created;
604
+ }
605
+ if (expect.kind === "torn") {
606
+ if (fresh.state !== "torn")
607
+ return "changed";
608
+ }
609
+ else {
610
+ if (fresh.state !== "held")
611
+ return "changed";
612
+ // Identity is the pid AND the instant it wrote, so a reused pid holding
613
+ // a fresh lease is not mistaken for the dead holder that was judged.
614
+ if (fresh.holder.pid !== expect.holder.pid)
615
+ return "changed";
616
+ if (fresh.holder.startedAt !== expect.holder.startedAt)
617
+ return "changed";
618
+ }
619
+ options.inside?.();
620
+ // The last thing before the write: is this entry still the one that holds
621
+ // the section? A process that stalled long enough to be evicted must not
622
+ // rename over the lease that replaced it.
623
+ if (!holdsSection(lockPath, pid, nonce))
624
+ return "busy";
625
+ const written = writeAtomic(path, body, pid);
626
+ return written === null ? "taken" : { detail: written };
627
+ }
628
+ finally {
629
+ // Only ours, and only while it is still ours: unlinking unconditionally is
630
+ // how a stalled process hands the next holder's section to a third.
631
+ if (holdsSection(lockPath, pid, nonce)) {
632
+ try {
633
+ unlinkSync(lockPath);
634
+ }
635
+ catch {
636
+ /* advisory: a lock whose process is gone is evicted by the next taker */
637
+ }
638
+ }
639
+ }
640
+ }
641
+ function leaseFor(path, mode, pid) {
642
+ let released = false;
643
+ return {
644
+ path,
645
+ mode,
646
+ pid,
647
+ release: () => {
648
+ if (released)
649
+ return;
650
+ released = true;
651
+ const read = readHolder(path);
652
+ if (read.state !== "held" || read.holder.pid !== pid)
653
+ return;
654
+ try {
655
+ unlinkSync(path);
656
+ }
657
+ catch {
658
+ /* advisory: a lease that could not be removed is reclaimed by the next taker's liveness probe */
659
+ }
660
+ },
661
+ };
662
+ }
663
+ //# sourceMappingURL=channel-lease.js.map