@timqi/pier 0.0.17 → 0.0.20

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 (95) hide show
  1. package/dist/agent/config-sync.js +123 -0
  2. package/dist/agent/config.js +95 -3
  3. package/dist/agent/events.js +26 -5
  4. package/dist/agent/listing.js +12 -13
  5. package/dist/agent/pi.js +75 -27
  6. package/dist/boards/boards.js +7 -6
  7. package/dist/channels/attach.js +18 -4
  8. package/dist/channels/config.js +7 -2
  9. package/dist/channels/dedup.js +9 -1
  10. package/dist/channels/lark.js +2 -6
  11. package/dist/channels/receipts.js +21 -6
  12. package/dist/channels/slack-tool.js +86 -25
  13. package/dist/channels/slack.js +2 -10
  14. package/dist/channels/telegram.js +1 -0
  15. package/dist/config-sync-fetch.js +97 -0
  16. package/dist/config-sync-task.js +59 -0
  17. package/dist/config-sync.js +179 -0
  18. package/dist/core/hub.js +23 -6
  19. package/dist/core/inbox.js +1 -1
  20. package/dist/core/reply.js +76 -2
  21. package/dist/core/router.js +162 -37
  22. package/dist/db.js +50 -0
  23. package/dist/main.js +32 -1
  24. package/dist/secrets.js +18 -6
  25. package/dist/tasks/agent.js +25 -23
  26. package/dist/tasks/callbacks.js +1 -1
  27. package/dist/tasks/definitions.js +28 -17
  28. package/dist/tasks/execution.js +11 -3
  29. package/dist/tasks/messages.js +58 -16
  30. package/dist/tasks/routes.js +58 -10
  31. package/dist/tasks/runs.js +0 -5
  32. package/dist/tasks/service.js +14 -4
  33. package/dist/tasks/store.js +87 -10
  34. package/dist/tasks/tool.js +8 -4
  35. package/dist/tools.js +32 -2
  36. package/dist/web/auth.js +17 -23
  37. package/dist/web/config-sync.js +91 -0
  38. package/dist/web/config.js +61 -0
  39. package/dist/web/explorer.js +20 -70
  40. package/dist/web/fs.js +159 -0
  41. package/dist/web/instance.js +11 -0
  42. package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
  43. package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
  44. package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
  45. package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
  46. package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
  47. package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
  48. package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
  49. package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
  50. package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
  51. package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
  52. package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
  53. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
  54. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
  55. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  56. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  57. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  58. package/dist/web/public/assets/{index-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
  59. package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
  60. package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
  61. package/dist/web/public/assets/index-WrsNstrb.js +79 -0
  62. package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
  63. package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
  64. package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
  65. package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
  66. package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
  67. package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
  68. package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
  69. package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
  70. package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
  71. package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
  72. package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
  73. package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
  74. package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
  75. package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
  76. package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
  77. package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
  78. package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
  79. package/dist/web/public/icon.svg.br +0 -0
  80. package/dist/web/public/icon.svg.gz +0 -0
  81. package/dist/web/public/index.html +13 -2
  82. package/dist/web/public/index.html.br +0 -0
  83. package/dist/web/public/index.html.gz +0 -0
  84. package/dist/web/public/manifest.webmanifest.br +0 -0
  85. package/dist/web/public/manifest.webmanifest.gz +0 -0
  86. package/dist/web/public/sw.js.br +0 -0
  87. package/dist/web/public/sw.js.gz +0 -0
  88. package/dist/web/route.js +17 -0
  89. package/dist/web/server.js +152 -66
  90. package/dist/web/terminal.js +2 -7
  91. package/package.json +2 -2
  92. package/skills/pier-slack/SKILL.md +4 -16
  93. package/skills/pier-tasks/SKILL.md +14 -9
  94. package/dist/web/files.js +0 -183
  95. package/dist/web/public/assets/index-DAxKtidH.js +0 -93
@@ -4,6 +4,9 @@
4
4
  // the second copy had already appeared and the map has an invariant that is
5
5
  // easy to lose on a rewrite: it is fed by every message in every chat the bot
6
6
  // is in, so it must be bounded and time-limited, never grow-only.
7
+ /** Fraction of `max` a full map is cut back to, so the eviction walk is paid
8
+ * once per that many messages instead of once per message. */
9
+ const KEEP = 0.9;
7
10
  export class Dedup {
8
11
  log;
9
12
  ttlMs;
@@ -28,8 +31,13 @@ export class Dedup {
28
31
  // is a forgotten id under extreme load (a redelivery slips through,
29
32
  // which downstream handling tolerates); unbounded memory is worse.
30
33
  // Map iterates in insertion order, so the front is the oldest.
34
+ //
35
+ // Evicting down to `KEEP` rather than to exactly `max` is what makes
36
+ // the walk amortized: at the bound, freeing one slot per message meant
37
+ // re-walking the whole map on every message from then on.
38
+ const keep = Math.floor(this.max * KEEP);
31
39
  for (const [id] of this.seen) {
32
- if (this.seen.size < this.max)
40
+ if (this.seen.size <= keep)
33
41
  break;
34
42
  this.seen.delete(id);
35
43
  }
@@ -44,7 +44,6 @@ const DRAIN_TIMEOUT_MS = 5000;
44
44
  /** How long a delivered event id is remembered, against redelivery. */
45
45
  const DEDUP_TTL_MS = 5 * 60_000;
46
46
  const DEDUP_MAX = 2000;
47
- const SWEEP_EVERY_MS = 60_000;
48
47
  /**
49
48
  * A Lark conversation is always `<chatId>/<rootMessageId>` — the thread is
50
49
  * the session. This pair is the only definition of the format; control.ts
@@ -87,7 +86,6 @@ export class LarkChannel {
87
86
  out;
88
87
  socket;
89
88
  running = false;
90
- sweptAt = 0;
91
89
  constructor(deps) {
92
90
  this.deps = deps;
93
91
  const config = deps.store.get("lark");
@@ -143,10 +141,8 @@ export class LarkChannel {
143
141
  onEvent(event, onMessage) {
144
142
  if (!this.running)
145
143
  return;
146
- if (Date.now() - this.sweptAt > SWEEP_EVERY_MS) {
147
- this.sweptAt = Date.now();
148
- void this.receipts.sweep();
149
- }
144
+ // Asked on every event, throttled inside receipts.ts.
145
+ void this.receipts.sweep();
150
146
  // Our own echo or another app's message.
151
147
  if (event.senderType === "app")
152
148
  return;
@@ -8,6 +8,14 @@
8
8
  // it finds at startup (nothing in memory can be its own yet), and sweeps its
9
9
  // own stragglers on a timer.
10
10
  import { pierDb } from "../db.js";
11
+ /**
12
+ * How often the straggler sweep may really run. Adapters ask on the inbound
13
+ * path — per envelope, or per `getUpdates` round trip — and the books only
14
+ * change on the scale of `staleMs`, so a busy chat would otherwise run this
15
+ * query hundreds of times a minute. Throttled here rather than in each
16
+ * adapter, which is where the same timestamp had been copied twice already.
17
+ */
18
+ const SWEEP_EVERY_MS = 60_000;
11
19
  const toReceipt = (row) => ({
12
20
  conversationId: row.conversation_id,
13
21
  chatId: row.chat_id,
@@ -68,6 +76,7 @@ export class Receipts {
68
76
  staleMs;
69
77
  /** In-flight `setReaction` per marked message. Only this process's own. */
70
78
  applying = new Map();
79
+ sweptAt = 0;
71
80
  constructor(api, ledger, log, emoji,
72
81
  /** After this, a receipt's turn is assumed never to settle. */
73
82
  staleMs) {
@@ -106,14 +115,20 @@ export class Receipts {
106
115
  * ours yet — and past `staleMs` a receipt's turn is never going to settle.
107
116
  */
108
117
  sweep(all = false) {
118
+ const now = Date.now();
119
+ // `all` is the startup sweep: it takes everything, so it is never skipped.
120
+ if (!all && now - this.sweptAt < SWEEP_EVERY_MS)
121
+ return Promise.resolve();
122
+ this.sweptAt = now;
109
123
  return this.clear(this.ledger.takeStale(all ? 0 : this.staleMs));
110
124
  }
111
125
  async clear(receipts) {
112
- for (const { chatId, messageId } of receipts) {
113
- const key = `${chatId}:${messageId}`;
114
- await this.applying.get(key);
115
- this.applying.delete(key);
116
- await this.api.setReaction(chatId, messageId, null).catch(() => { });
117
- }
126
+ // Wait together, then launch clears in booking order: if one apply is slow,
127
+ // it must not let a later receipt clear first.
128
+ await Promise.all(receipts.map(({ chatId, messageId }) => this.applying.get(`${chatId}:${messageId}`)));
129
+ for (const { chatId, messageId } of receipts)
130
+ this.applying.delete(`${chatId}:${messageId}`);
131
+ await Promise.all(receipts.map(({ chatId, messageId }) => this.api.setReaction(chatId, messageId, null)
132
+ .catch((err) => this.log(`reaction clear failed: ${String(err)}`))));
118
133
  }
119
134
  }
@@ -27,19 +27,25 @@ const tsToNumber = (ts) => Number(ts);
27
27
  const tsToIso = (ts) => new Date(Math.floor(tsToNumber(ts) * 1000)).toISOString();
28
28
  /** Minute precision in the transcript: the exact time is in the `ts` beside it. */
29
29
  const tsToMinute = (ts) => `${tsToIso(ts).slice(0, 16)}Z`;
30
+ /** Epoch seconds at the year 2100: past this, the caller meant milliseconds. */
31
+ const MAX_SECONDS = 4_102_444_800;
30
32
  /** Accepts an ISO date, an epoch-seconds number, or a raw Slack ts. */
31
33
  export function toTs(value) {
32
34
  if (value === undefined || value === "")
33
35
  return undefined;
34
- if (typeof value === "number")
35
- return String(value);
36
- const trimmed = value.trim();
37
- if (/^\d+(\.\d+)?$/.test(trimmed))
38
- return trimmed;
39
- const parsed = Date.parse(trimmed);
40
- if (Number.isNaN(parsed))
36
+ const raw = typeof value === "number" ? String(value) : value.trim();
37
+ // A ts is passed through untouched — it is an id, not a number to reformat.
38
+ const numeric = /^\d+(\.\d+)?$/.test(raw);
39
+ const seconds = numeric ? Number(raw) : Date.parse(raw) / 1000;
40
+ if (Number.isNaN(seconds))
41
41
  throw new Error(`not a time: ${value}`);
42
- return String(parsed / 1000);
42
+ // Milliseconds are the mistake worth naming: Slack takes the window without
43
+ // complaint, finds nothing that far in the future, and answers with an empty
44
+ // read that is indistinguishable from a channel where nobody spoke.
45
+ if (seconds > MAX_SECONDS) {
46
+ throw new Error(`${value} is past the year 2100 — Slack times are epoch seconds, not milliseconds`);
47
+ }
48
+ return numeric ? raw : String(seconds);
43
49
  }
44
50
  /**
45
51
  * Whether a session opened now is given the tool at all: the same two switches
@@ -91,6 +97,8 @@ export function slackToolSpec(execute, available) {
91
97
  text: Type.Optional(Type.String()),
92
98
  }),
93
99
  available,
100
+ // Stands down with the tool: without Slack, its manual is a route to nowhere.
101
+ skill: "pier-slack",
94
102
  execute,
95
103
  };
96
104
  }
@@ -165,10 +173,12 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
165
173
  // own bounds are inclusive-ish, so the boundary message is dropped here
166
174
  // rather than trusted to the API.
167
175
  const after = toTs(input.after);
176
+ // Both reads take it, so it is read once: a cap that worked on a channel and
177
+ // was ignored on a thread would be the more expensive kind of surprise.
178
+ const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
168
179
  if (input.operation === "read_channel") {
169
180
  const since = after ?? toTs(input.since);
170
181
  const until = toTs(input.until);
171
- const limit = Math.min(Number(input.limit) || MAX_MESSAGES, MAX_MESSAGES);
172
182
  return readChannel(deps, client, channel, since, until, after, limit);
173
183
  }
174
184
  if (input.operation === "read_thread") {
@@ -177,7 +187,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
177
187
  : at?.threadTs;
178
188
  if (!threadTs)
179
189
  throw new Error("thread_ts is required outside a Slack thread");
180
- return readThread(deps, client, channel, threadTs, after);
190
+ return readThread(deps, client, channel, threadTs, after, limit);
181
191
  }
182
192
  if (input.operation === "read_message") {
183
193
  const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
@@ -204,6 +214,7 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
204
214
  at: sent.ts ? tsToIso(sent.ts) : null,
205
215
  // Returned so a follow-up can reply under what was just posted.
206
216
  threadTs: threadTs ?? sent.ts,
217
+ ...inertMention(text),
207
218
  };
208
219
  }
209
220
  if (input.operation === "edit") {
@@ -211,14 +222,23 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
211
222
  // and Slack keeps no visible record of what it said before.
212
223
  const ts = required(input.ts, "ts");
213
224
  const text = messageText(input.text);
225
+ // Defaulted from `here` as `post` is: a correction is nearly always to a
226
+ // reply the agent made in this thread, and `conversations.history` cannot
227
+ // see inside a thread — without this the read below always comes up empty.
228
+ const asked = typeof input.thread_ts === "string" ? input.thread_ts.trim() : "";
229
+ const inThread = asked || (channel === at?.channel ? at?.threadTs : undefined);
230
+ // Read before the write, because after it the old wording exists nowhere:
231
+ // Slack keeps no version, so this log is the only record of what was
232
+ // replaced. Best-effort — a failed read must not block the correction.
233
+ const was = await previousText(client, channel, ts, inThread);
214
234
  try {
215
235
  await client.updateMessage({ channel, ts, text, blocks: [{ type: "markdown", text }] });
216
236
  }
217
237
  catch (err) {
218
238
  throw new Error(explain(err));
219
239
  }
220
- deps.log(`slack tool edited ${ts} in ${channel}`);
221
- return { channel, ts, edited: true };
240
+ deps.log(`slack tool edited ${ts} in ${channel}; was: ${was ?? "(not captured)"}`);
241
+ return { channel, ts, edited: true, ...inertMention(text) };
222
242
  }
223
243
  if (input.operation === "delete") {
224
244
  // Never defaulted from `here`: the thread's ts is the parent message, and
@@ -237,6 +257,50 @@ export async function handleSlackTool(deps, raw, callerSessionId = "") {
237
257
  }
238
258
  throw new Error(`unknown slack operation: ${String(input.operation)}`);
239
259
  }
260
+ /**
261
+ * The one message at `ts`. A `ts` is unique only within its conversation, and
262
+ * `conversations.history` never returns what was posted inside a thread — so a
263
+ * reply has to be asked for through its thread, and only the caller knows.
264
+ */
265
+ async function oneMessage(client, channel, ts, threadTs) {
266
+ const page = threadTs
267
+ ? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
268
+ : await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
269
+ return page.messages.find((msg) => msg.ts === ts);
270
+ }
271
+ /** One line of what a message said, for the log an edit leaves behind. */
272
+ async function previousText(client, channel, ts, threadTs) {
273
+ try {
274
+ const text = (await oneMessage(client, channel, ts, threadTs))?.text;
275
+ return text === undefined ? undefined : text.slice(0, 200).replace(/\s+/g, " ");
276
+ }
277
+ catch {
278
+ // Not swallowed: the caller logs that the old wording was not captured,
279
+ // which is the fact that matters. Refusing the edit over it would be worse.
280
+ return undefined;
281
+ }
282
+ }
283
+ /**
284
+ * A plain `@alice` is the one Slack mistake that looks like it worked: the
285
+ * message goes up, renders as text, and notifies nobody. Reported after the
286
+ * fact rather than refused — a name in prose is legitimate, an unping is not
287
+ * worth losing the message over.
288
+ */
289
+ function inertMention(text) {
290
+ const prose = text
291
+ .replace(/```[\s\S]*?```|`[^`]*`/g, "") // code says @ and # for other reasons
292
+ .replace(/<[^>]*>/g, ""); // already Slack syntax
293
+ const hit = /(?:^|\s)([@#][A-Za-z][\w.-]*)/.exec(prose)?.[1];
294
+ if (!hit)
295
+ return {};
296
+ const as = hit.startsWith("@")
297
+ ? /^@(here|channel|everyone)$/.test(hit) ? `<!${hit.slice(1)}>` : "<@U…>"
298
+ : "<#C…>";
299
+ return {
300
+ hint: `${hit} is plain text and notified nobody — Slack needs ${as}. ` +
301
+ `Edit this ts if it was meant to reach someone.`,
302
+ };
303
+ }
240
304
  /** Accept a `#name` or a bare name as well as an id — models prefer names. */
241
305
  function resolveChannel(deps, given) {
242
306
  if (/^[CDG][A-Z0-9]+$/.test(given))
@@ -265,30 +329,27 @@ async function readChannel(deps, client, channel, since, until, after, limit) {
265
329
  messages: await lines(deps, client, window),
266
330
  };
267
331
  }
268
- async function readThread(deps, client, channel, threadTs, after) {
332
+ async function readThread(deps, client, channel, threadTs, after, limit) {
269
333
  const fetched = await fetchPages(deps, (cursor) => client.replies(channel, threadTs, { oldest: after, cursor }), `thread ${threadTs} in ${channel}`);
270
- const messages = newerThan(transcript(fetched.messages), after);
334
+ const all = newerThan(transcript(fetched.messages), after);
335
+ // Oldest first, as in a channel read: a thread cut at its newest end still
336
+ // reads as a thread, and the cut has to be said either way — a long thread
337
+ // that answers as if it were complete is the read nobody double-checks.
338
+ const messages = all.slice(0, limit);
271
339
  return {
272
340
  channel,
273
341
  // Hoisted: every line in a thread carries the same one.
274
342
  threadTs,
275
343
  count: messages.length,
344
+ ...(fetched.truncated || all.length > messages.length ? { truncated: true } : {}),
276
345
  ...(fetched.incomplete ? { incomplete: fetched.incomplete } : {}),
277
346
  format: LINE_FORMAT,
278
347
  messages: await lines(deps, client, messages),
279
348
  };
280
349
  }
281
- /**
282
- * One message, because that is sometimes the whole question. A `ts` is unique
283
- * only within its conversation, and `conversations.history` never returns what
284
- * was posted inside a thread — so a reply has to be asked for through its
285
- * thread, and saying which one is the caller's job.
286
- */
350
+ /** One message, because that is sometimes the whole question. */
287
351
  async function readMessage(deps, client, channel, ts, threadTs) {
288
- const page = threadTs
289
- ? await client.replies(channel, threadTs, { oldest: ts, limit: 20 })
290
- : await client.history(channel, { oldest: ts, latest: ts, limit: 1 });
291
- const found = page.messages.find((msg) => msg.ts === ts);
352
+ const found = await oneMessage(client, channel, ts, threadTs);
292
353
  if (!found) {
293
354
  throw new Error(threadTs
294
355
  ? `no message ${ts} in thread ${threadTs}`
@@ -342,7 +403,7 @@ function explain(err) {
342
403
  const code = /slack [\w.]+: (\w+)/.exec(String(err))?.[1] ?? "";
343
404
  return {
344
405
  channel_not_found: "no such channel, or Pier's bot cannot see it — check the channels operation",
345
- not_in_channel: "Pier's bot is not in that channel; someone has to invite it before it can read",
406
+ not_in_channel: "Pier's bot is not in that channel; someone has to run `/invite @Pier` there before it can read",
346
407
  missing_scope: "Pier's Slack app lacks the scope for this call; the operator must reinstall it",
347
408
  ratelimited: "Slack rate-limited Pier; wait a minute, and narrow the range if this was a read",
348
409
  thread_not_found: "no thread with that ts in this channel",
@@ -52,8 +52,6 @@ const DRAIN_TIMEOUT_MS = 5000;
52
52
  */
53
53
  const DEDUP_TTL_MS = 5 * 60_000;
54
54
  const DEDUP_MAX = 2000;
55
- /** How often the straggler sweep may run; see onEnvelope. */
56
- const SWEEP_EVERY_MS = 60_000;
57
55
  /**
58
56
  * Commands that may appear as a bare word, and exactly how many arguments each
59
57
  * takes. Both halves are load-bearing, because Slack gives us no leading `/` to
@@ -132,7 +130,6 @@ export class SlackChannel {
132
130
  out;
133
131
  socket;
134
132
  running = false;
135
- sweptAt = 0;
136
133
  constructor(deps) {
137
134
  this.deps = deps;
138
135
  const config = deps.store.get("slack");
@@ -195,13 +192,8 @@ export class SlackChannel {
195
192
  onEnvelope(env, onMessage) {
196
193
  if (!this.running)
197
194
  return;
198
- // Envelopes arrive pushed, not in polled batches, so the sweep is timed
199
- // rather than per-envelope: a busy channel would otherwise run this query
200
- // hundreds of times a minute for something that changes every 30.
201
- if (Date.now() - this.sweptAt > SWEEP_EVERY_MS) {
202
- this.sweptAt = Date.now();
203
- void this.receipts.sweep();
204
- }
195
+ // Asked on every envelope, throttled inside receipts.ts.
196
+ void this.receipts.sweep();
205
197
  if (env.type === "events_api") {
206
198
  const payload = env.payload;
207
199
  const event = payload?.event;
@@ -124,6 +124,7 @@ export class TelegramChannel {
124
124
  const updates = await this.api.getUpdates(this.offset, POLL_SECONDS);
125
125
  if (!this.running)
126
126
  return;
127
+ // Asked on every round trip, throttled inside receipts.ts.
127
128
  void this.receipts.sweep();
128
129
  if (!updates.length && Date.now() - startedAt < 1000) {
129
130
  await new Promise((r) => setTimeout(r, 1000));
@@ -0,0 +1,97 @@
1
+ // Fetch one configuration document without turning an operator URL into an
2
+ // arbitrary network proxy. DNS is checked once and the TLS request is pinned
3
+ // to that address; redirects never inherit the token or bypass the check.
4
+ import { lookup } from "node:dns/promises";
5
+ import { request } from "node:https";
6
+ import { BlockList, isIP } from "node:net";
7
+ import { checkServerIdentity } from "node:tls";
8
+ export const CONFIG_SYNC_BYTES = 1024 * 1024;
9
+ const blockedV4 = new BlockList();
10
+ const blockedV6 = new BlockList();
11
+ for (const [address, prefix] of [
12
+ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
13
+ ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16],
14
+ ["198.18.0.0", 15], ["224.0.0.0", 4], ["240.0.0.0", 4],
15
+ ])
16
+ blockedV4.addSubnet(address, prefix, "ipv4");
17
+ for (const [address, prefix] of [
18
+ ["::", 96], ["::ffff:0:0", 96], ["64:ff9b::", 96], ["64:ff9b:1::", 48],
19
+ ["100::", 64], ["2001::", 32], ["2002::", 16], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
20
+ ])
21
+ blockedV6.addSubnet(address, prefix, "ipv6");
22
+ const globalV6 = new BlockList();
23
+ globalV6.addSubnet("2000::", 3, "ipv6");
24
+ export function configSourceUrl(raw) {
25
+ let url;
26
+ try {
27
+ url = new URL(raw.trim());
28
+ }
29
+ catch {
30
+ throw new Error("A valid HTTPS source URL is required");
31
+ }
32
+ if (url.protocol !== "https:" || url.username || url.password || url.hash || raw.length > 4096) {
33
+ throw new Error("Source must be HTTPS, without credentials or a fragment");
34
+ }
35
+ return url;
36
+ }
37
+ export function publicConfigAddress(address) {
38
+ const family = isIP(address);
39
+ return family !== 0 && (family !== 6 || globalV6.check(address, "ipv6")) &&
40
+ !(family === 4 ? blockedV4 : blockedV6).check(address, family === 4 ? "ipv4" : "ipv6");
41
+ }
42
+ export async function downloadConfig(raw, etag, signal) {
43
+ const url = configSourceUrl(raw);
44
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
45
+ const addresses = isIP(hostname)
46
+ ? [{ address: hostname, family: isIP(hostname) }]
47
+ : await Promise.race([
48
+ lookup(hostname, { all: true }),
49
+ new Promise((_, reject) => {
50
+ if (signal.aborted)
51
+ reject(new Error("Configuration download aborted"));
52
+ else
53
+ signal.addEventListener("abort", () => reject(new Error("Configuration download aborted")), { once: true });
54
+ }),
55
+ ]);
56
+ if (!addresses.length || addresses.some(({ address }) => !publicConfigAddress(address))) {
57
+ throw new Error("Source must resolve to a public address");
58
+ }
59
+ signal.throwIfAborted();
60
+ const address = addresses[0];
61
+ return new Promise((resolve, reject) => {
62
+ const req = request({
63
+ hostname: address.address, family: address.family, port: url.port || 443,
64
+ servername: isIP(hostname) ? undefined : hostname,
65
+ checkServerIdentity: (_host, cert) => checkServerIdentity(hostname, cert),
66
+ path: `${url.pathname}${url.search}`, method: "GET", signal,
67
+ headers: { host: url.host, accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) },
68
+ }, (res) => {
69
+ const status = res.statusCode;
70
+ if (status !== 200 && status !== 304) {
71
+ res.destroy();
72
+ reject(new Error(status === 404 || status === 410
73
+ ? "Source link was revoked or does not exist"
74
+ : `Source returned HTTP ${String(status)}`));
75
+ return;
76
+ }
77
+ if (status === 200 && !/^application\/json(?:\s*;|$)/i.test(res.headers["content-type"] ?? "")) {
78
+ res.destroy();
79
+ reject(new Error("Source did not return JSON"));
80
+ return;
81
+ }
82
+ const chunks = [];
83
+ let size = 0;
84
+ res.on("data", (chunk) => {
85
+ size += chunk.length;
86
+ if (size > CONFIG_SYNC_BYTES)
87
+ res.destroy(new Error("Configuration exceeds 1 MiB"));
88
+ else
89
+ chunks.push(chunk);
90
+ });
91
+ res.on("error", () => reject(new Error(size > CONFIG_SYNC_BYTES ? "Configuration exceeds 1 MiB" : "Configuration download failed")));
92
+ res.on("end", () => resolve({ status, etag: res.headers.etag ?? null, body: Buffer.concat(chunks).toString("utf8") }));
93
+ });
94
+ req.on("error", () => reject(new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Could not connect to source")));
95
+ req.end();
96
+ });
97
+ }
@@ -0,0 +1,59 @@
1
+ // The single hourly task owned by the configuration subscription. The task
2
+ // history records manual and scheduled checks; its enabled bit follows the
3
+ // subscription, never a second independently editable switch.
4
+ const OWNER = "config-sync";
5
+ export function configSyncTask(tasks, sync) {
6
+ let taskId = null;
7
+ let reconciliation = Promise.resolve();
8
+ const reconcile = () => {
9
+ const next = reconciliation.then(async () => {
10
+ const owned = tasks.list().filter((task) => task.creator === OWNER && !task.archived);
11
+ for (const extra of owned.slice(1))
12
+ tasks.archive(extra.id, OWNER);
13
+ const status = sync.status();
14
+ if (!owned.length && !status.sourceUrl)
15
+ return;
16
+ const draft = {
17
+ name: "Configuration sync",
18
+ description: "Checks the configuration source from Settings > Agent every hour.",
19
+ enabled: status.enabled,
20
+ trigger: { type: "cron", expression: "23 * * * *", timezone: "UTC" },
21
+ action: { type: "system", name: OWNER },
22
+ callback: { type: "none" },
23
+ timeoutSeconds: 30,
24
+ };
25
+ const task = owned[0];
26
+ if (!task)
27
+ taskId = (await tasks.create(draft, OWNER)).id;
28
+ else {
29
+ taskId = task.id;
30
+ if (Object.entries(draft).some(([key, value]) => JSON.stringify(task[key]) !== JSON.stringify(value))) {
31
+ await tasks.update(task.id, draft, OWNER);
32
+ }
33
+ }
34
+ });
35
+ // A failed reconciliation must be retryable, but the caller still receives
36
+ // the original rejection and reports it on its originating surface.
37
+ reconciliation = next.then(() => undefined, () => undefined);
38
+ return next;
39
+ };
40
+ const run = async () => {
41
+ await reconcile();
42
+ if (!taskId || !sync.status().enabled)
43
+ throw new Error("Configuration subscription is paused");
44
+ const active = tasks.activeRun(taskId);
45
+ const started = active ?? tasks.run(taskId, null, "manual");
46
+ const done = await tasks.waitForRun(started.id);
47
+ if (done.state !== "succeeded")
48
+ throw new Error(done.error ?? `Configuration sync ${done.state}`);
49
+ return done.result?.type === "system" ? done.result.text : "Configuration sync finished";
50
+ };
51
+ return {
52
+ reconcile,
53
+ run,
54
+ status: () => ({
55
+ ...sync.status(), taskId,
56
+ nextRunAt: taskId ? tasks.get(taskId).nextRunAt : null,
57
+ }),
58
+ };
59
+ }