approval-md 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -24
- package/SPEC.md +57 -11
- package/dist/src/channels/contract.d.ts +34 -1
- package/dist/src/channels/contract.js +200 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/telegram.d.ts +123 -11
- package/dist/src/channels/telegram.js +218 -23
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +9 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/amend.js +214 -30
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.d.ts +9 -0
- package/dist/src/cli/attest.js +134 -7
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +99 -26
- package/dist/src/cli/channel-telegram.js +311 -13
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +9 -0
- package/dist/src/cli/channel.js +9 -0
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +1 -1
- package/dist/src/cli/codex.js +304 -7
- package/dist/src/cli/codex.js.map +1 -1
- package/dist/src/cli/daemon.js +4 -1
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.js +467 -12
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.js +25 -2
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/help.d.ts +6 -2
- package/dist/src/cli/help.js +165 -60
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +49 -1
- package/dist/src/cli/hook-codex.js +60 -1
- package/dist/src/cli/hook-codex.js.map +1 -1
- package/dist/src/cli/hook.d.ts +459 -3
- package/dist/src/cli/hook.js +1062 -114
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/main.js +5 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +151 -13
- package/dist/src/cli/preflight.js +398 -41
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +1 -1
- package/dist/src/cli/scaffold.js +1 -1
- package/dist/src/cli/setup-channel.d.ts +9 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-common.d.ts +3 -1
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup.d.ts +2 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/up.js +115 -51
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/verb-registry.js +174 -9
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +51 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +20 -18
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/attest.d.ts +215 -0
- package/dist/src/core/attest.js +317 -7
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +18 -0
- package/dist/src/core/audit.js +13 -0
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/command-class.d.ts +154 -0
- package/dist/src/core/command-class.js +673 -20
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +109 -8
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +23 -2
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +5 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +15 -2
- package/dist/src/core/execute.js +15 -2
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/gate.d.ts +86 -1
- package/dist/src/core/gate.js +81 -1
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +1 -1
- package/dist/src/core/harness-version.js +3 -1
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/instance.d.ts +59 -2
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/log.d.ts +39 -1
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +10 -0
- package/dist/src/core/policy-explain.js +32 -0
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +41 -1
- package/dist/src/core/policy-load.js +21 -3
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +43 -0
- package/dist/src/core/policy-match.js +52 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +52 -0
- package/dist/src/core/policy-proposal.js +102 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/protected-path-guard.d.ts +117 -4
- package/dist/src/core/protected-path-guard.js +362 -48
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +172 -0
- package/dist/src/core/read-scope.js +252 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +81 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +476 -0
- package/dist/src/core/sender-identity.js +572 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/values.d.ts +18 -8
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/daemon/advance.d.ts +10 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/daemon.js +9 -0
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/mcp/server.js +8 -0
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/cli-reference.md +932 -32
- package/docs/codex-enforced-session.md +75 -2
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +3 -1
- package/schema/event.schema.json +538 -9
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +54 -2
- package/schema/values.schema.json +7 -11
- package/schema/fixtures/values/invalid/version-string.json +0 -1
|
@@ -40,16 +40,35 @@
|
|
|
40
40
|
* > flag); the trust boundary is the local machine, and anyone who can set that
|
|
41
41
|
* > configuration and write to the log is inside it.
|
|
42
42
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
43
|
+
* What this channel authenticates, exactly: that the callback arrived **from
|
|
44
|
+
* the configured chat id**, and which **Telegram account** the Bot API
|
|
45
|
+
* attributes the tap to (`callback_query.from.id`). It authenticates no
|
|
46
|
+
* person, because no transport can: an account id is evidence about an
|
|
47
|
+
* account.
|
|
48
|
+
*
|
|
49
|
+
* What is done with the second fact is the operator's to decide, and since
|
|
50
|
+
* APRV-324 there are two settings:
|
|
51
|
+
*
|
|
52
|
+
* - **No `senders` block in the policy.** Nothing changes from every build
|
|
53
|
+
* before it. The decision is recorded against the human actor the *runtime*
|
|
54
|
+
* was configured with (`APPROVAL_HUMAN` / `--as`), so the guarantee is
|
|
55
|
+
* "someone with access to the configured chat, on a runtime configured by
|
|
56
|
+
* someone with local control, tapped Approve" — not "alice tapped Approve".
|
|
57
|
+
* Anyone in that chat can approve as the configured actor. Use a private chat
|
|
58
|
+
* with the bot, and treat the chat's membership as part of the trust
|
|
59
|
+
* boundary.
|
|
60
|
+
* - **A `senders` block mapping account ids to approvers.** The decision is
|
|
61
|
+
* recorded against the person the operator attested that account to, and a
|
|
62
|
+
* tap from an account the policy does not name is REFUSED rather than
|
|
63
|
+
* recorded under the listener's identity. The guarantee becomes "the account
|
|
64
|
+
* the operator attested to alice tapped Approve", which is a statement about
|
|
65
|
+
* Telegram's session handling and the operator's assertion, and still not a
|
|
66
|
+
* proof of personhood. Cryptographic identity is future work and is not a
|
|
67
|
+
* v0.1 claim.
|
|
68
|
+
*
|
|
69
|
+
* The channel itself resolves nothing either way. It reports the account it
|
|
70
|
+
* saw; `channels/contract.ts` resolves it against the attested policy, and
|
|
71
|
+
* `core/gate.ts` decides. See `design/channel-sender-identity.md`.
|
|
53
72
|
*
|
|
54
73
|
* ## Formatting: HTML, not MarkdownV2 — a deliberate choice
|
|
55
74
|
*
|
|
@@ -1206,6 +1225,42 @@ export function parseCheckpointCallback(data) {
|
|
|
1206
1225
|
return null;
|
|
1207
1226
|
return { sign: verb === "k", nonce };
|
|
1208
1227
|
}
|
|
1228
|
+
/**
|
|
1229
|
+
* The account the Bot API attributes a callback to (APRV-324, amended SPEC.md
|
|
1230
|
+
* §10.3).
|
|
1231
|
+
*
|
|
1232
|
+
* `callback_query.from.id` and nothing else. Telegram assembles the `from`
|
|
1233
|
+
* object itself, from the session the tap arrived on, which is why it is the
|
|
1234
|
+
* one field on an update this channel treats as evidence about a person. Three
|
|
1235
|
+
* neighbours are deliberately not read:
|
|
1236
|
+
*
|
|
1237
|
+
* - **`from.username`.** Mutable and reusable, so a mapping keyed on it would
|
|
1238
|
+
* hand an identity over with a handle. Never a key here and never recorded
|
|
1239
|
+
* (`core/sender-identity.ts` states the argument in full).
|
|
1240
|
+
* - **`message.from`.** The author of the message the button sits on — this bot
|
|
1241
|
+
* — rather than the person who pressed it.
|
|
1242
|
+
* - **`data`, and any text.** What the sender says about themselves, which
|
|
1243
|
+
* §11.1 invariant 4 says may raise scrutiny and never lower it. A callback
|
|
1244
|
+
* whose payload names a user id names it about itself; the id here comes from
|
|
1245
|
+
* the transport, and the two disagreeing changes nothing.
|
|
1246
|
+
*
|
|
1247
|
+
* `undefined` when the update carries no usable id: a sender this channel could
|
|
1248
|
+
* not read is not a sender it may guess at, and the decision then travels with
|
|
1249
|
+
* none, which the contract reads as today's configured attribution.
|
|
1250
|
+
*/
|
|
1251
|
+
export function senderOf(query, channel) {
|
|
1252
|
+
const from = query["from"];
|
|
1253
|
+
if (typeof from !== "object" || from === null)
|
|
1254
|
+
return undefined;
|
|
1255
|
+
const id = from["id"];
|
|
1256
|
+
// Telegram sends a JSON number; a string is accepted for the same reason the
|
|
1257
|
+
// chat check accepts one, and nothing else is: an object or an array here is
|
|
1258
|
+
// a shape this channel does not understand rather than an id to stringify.
|
|
1259
|
+
if (typeof id !== "number" && typeof id !== "string")
|
|
1260
|
+
return undefined;
|
|
1261
|
+
const text = String(id);
|
|
1262
|
+
return text.length === 0 ? undefined : { channel, id: text };
|
|
1263
|
+
}
|
|
1209
1264
|
const CALLBACK_VERBS = {
|
|
1210
1265
|
g: { decision: "grant", scope: "one" },
|
|
1211
1266
|
r: { decision: "reject", scope: "one" },
|
|
@@ -1494,6 +1549,29 @@ export function isMessageNotModified(cause) {
|
|
|
1494
1549
|
cause.description !== null &&
|
|
1495
1550
|
TELEGRAM_NOT_MODIFIED.test(cause.description));
|
|
1496
1551
|
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Telegram's wording for "somebody else is already long-polling this bot".
|
|
1554
|
+
*
|
|
1555
|
+
* Matched on the description as well as the status, for the same reason
|
|
1556
|
+
* {@link TELEGRAM_NOT_MODIFIED} is: 409 is a conflict, and the conflict this
|
|
1557
|
+
* project cares about is specifically the terminated-by-other-getUpdates one.
|
|
1558
|
+
*/
|
|
1559
|
+
const TELEGRAM_POLL_CONFLICT = /terminated by other getupdates|make sure that only one bot/iu;
|
|
1560
|
+
/**
|
|
1561
|
+
* Whether a failed poll is the Bot API saying another process holds this bot
|
|
1562
|
+
* (APRV-390).
|
|
1563
|
+
*
|
|
1564
|
+
* It is the one poll failure that is not transient and not the network's
|
|
1565
|
+
* fault: retrying it forever produces an identical line every few seconds and
|
|
1566
|
+
* never recovers, because the other poller is not going to stop. {@link
|
|
1567
|
+
* TelegramChannel.listen} reports it once, with whatever the caller knows
|
|
1568
|
+
* about who the other process is, and then stops repeating itself.
|
|
1569
|
+
*/
|
|
1570
|
+
export function isPollConflict(cause) {
|
|
1571
|
+
return (cause instanceof TelegramApiError &&
|
|
1572
|
+
cause.method === "getUpdates" &&
|
|
1573
|
+
(cause.status === 409 || (cause.description !== null && TELEGRAM_POLL_CONFLICT.test(cause.description))));
|
|
1574
|
+
}
|
|
1497
1575
|
export class TelegramChannel {
|
|
1498
1576
|
name = "telegram";
|
|
1499
1577
|
token;
|
|
@@ -2255,6 +2333,32 @@ export class TelegramChannel {
|
|
|
2255
2333
|
return String(result.message_id);
|
|
2256
2334
|
}
|
|
2257
2335
|
// -------------------------------------------------------------------------
|
|
2336
|
+
// Identity
|
|
2337
|
+
// -------------------------------------------------------------------------
|
|
2338
|
+
/**
|
|
2339
|
+
* Which bot this token is, from one `getMe` (APRV-390).
|
|
2340
|
+
*
|
|
2341
|
+
* `getMe` is the only Bot API call that answers it, and it is the call
|
|
2342
|
+
* `approval doctor` and `approval setup channel telegram` already make for
|
|
2343
|
+
* the same reason: it mutates nothing, sends nothing, and acknowledges
|
|
2344
|
+
* nothing. In particular it is NOT `getUpdates`, so asking who this bot is
|
|
2345
|
+
* consumes no update and steals no pending tap from a listener that is
|
|
2346
|
+
* already running — which matters here above all, because the answer is
|
|
2347
|
+
* what decides whether this process is allowed to poll at all.
|
|
2348
|
+
*
|
|
2349
|
+
* Throws {@link TelegramApiError} like every other call; the caller decides
|
|
2350
|
+
* whether an unreachable Bot API is a refusal or a shrug.
|
|
2351
|
+
*/
|
|
2352
|
+
async identify() {
|
|
2353
|
+
const result = await this.call("getMe", {});
|
|
2354
|
+
const id = result["id"];
|
|
2355
|
+
const username = result["username"];
|
|
2356
|
+
return {
|
|
2357
|
+
id: typeof id === "number" || typeof id === "string" ? String(id) : "",
|
|
2358
|
+
username: typeof username === "string" ? `@${username}` : "the bot",
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
// -------------------------------------------------------------------------
|
|
2258
2362
|
// Long polling
|
|
2259
2363
|
// -------------------------------------------------------------------------
|
|
2260
2364
|
/**
|
|
@@ -2277,6 +2381,16 @@ export class TelegramChannel {
|
|
|
2277
2381
|
async listen(options = {}) {
|
|
2278
2382
|
this.stopped = false;
|
|
2279
2383
|
let backoff = this.backoffMs;
|
|
2384
|
+
/**
|
|
2385
|
+
* APRV-390. The last failure reported, and how many identical ones have
|
|
2386
|
+
* been swallowed since. A 409 does not recover: the other poller is not
|
|
2387
|
+
* going to stop because this one asked again, so the pre-APRV-390 loop
|
|
2388
|
+
* printed the same sentence every few seconds until somebody read the
|
|
2389
|
+
* terminal. The retry itself stays — a listener that stops listening is
|
|
2390
|
+
* the failure this loop rules out — and only the COMPLAINING is collapsed.
|
|
2391
|
+
*/
|
|
2392
|
+
let lastComplaint = null;
|
|
2393
|
+
let repeats = 0;
|
|
2280
2394
|
while (!this.stopped) {
|
|
2281
2395
|
try {
|
|
2282
2396
|
if (options.beforePoll !== undefined) {
|
|
@@ -2286,6 +2400,11 @@ export class TelegramChannel {
|
|
|
2286
2400
|
}
|
|
2287
2401
|
await this.pollOnce();
|
|
2288
2402
|
backoff = this.backoffMs;
|
|
2403
|
+
if (repeats > 0) {
|
|
2404
|
+
this.complain(`approval: telegram getUpdates recovered after ${String(repeats)} further identical failure(s)`);
|
|
2405
|
+
}
|
|
2406
|
+
lastComplaint = null;
|
|
2407
|
+
repeats = 0;
|
|
2289
2408
|
if (options.once === true)
|
|
2290
2409
|
return;
|
|
2291
2410
|
}
|
|
@@ -2293,7 +2412,26 @@ export class TelegramChannel {
|
|
|
2293
2412
|
if (this.stopped)
|
|
2294
2413
|
return;
|
|
2295
2414
|
this.counters.pollErrors += 1;
|
|
2296
|
-
|
|
2415
|
+
const conflict = isPollConflict(cause);
|
|
2416
|
+
// The 409's own sentence, said once. It names the fact ("another
|
|
2417
|
+
// process is polling this bot") rather than the HTTP status, because
|
|
2418
|
+
// the status is what a reader was already staring at, and it carries
|
|
2419
|
+
// whatever the caller knows about which two gates are involved.
|
|
2420
|
+
const advice = conflict ? (options.conflictAdvice?.() ?? null) : null;
|
|
2421
|
+
const message = conflict
|
|
2422
|
+
? `approval: telegram getUpdates: another process is polling this bot${advice === null ? "" : ` — ${advice}`}; this listener keeps retrying and will not receive a tap until the other one stops`
|
|
2423
|
+
: `approval: telegram getUpdates failed (${this.describe(cause)}); retrying in ${backoff}ms — the listener is still up`;
|
|
2424
|
+
if (message === lastComplaint) {
|
|
2425
|
+
repeats += 1;
|
|
2426
|
+
}
|
|
2427
|
+
else {
|
|
2428
|
+
if (repeats > 0) {
|
|
2429
|
+
this.complain(`approval: telegram getUpdates: the previous line repeated ${String(repeats)} more time(s)`);
|
|
2430
|
+
}
|
|
2431
|
+
this.complain(message);
|
|
2432
|
+
lastComplaint = message;
|
|
2433
|
+
repeats = 0;
|
|
2434
|
+
}
|
|
2297
2435
|
await sleep(backoff);
|
|
2298
2436
|
backoff = Math.min(backoff * 2, this.maxBackoffMs);
|
|
2299
2437
|
}
|
|
@@ -2478,17 +2616,37 @@ export class TelegramChannel {
|
|
|
2478
2616
|
const chat = (message["chat"] ?? {});
|
|
2479
2617
|
const chatId = chat["id"] === undefined ? "" : String(chat["id"]);
|
|
2480
2618
|
// (a) Not our chat. Counted, answered, never decided, never logged.
|
|
2619
|
+
//
|
|
2620
|
+
// APRV-324 leaves this check exactly where it is, FIRST and ahead of the
|
|
2621
|
+
// sender, on purpose: the mapping widens WHO may decide and never WHERE
|
|
2622
|
+
// from. A callback carrying a perfectly mapped `from.id` but arriving in a
|
|
2623
|
+
// chat this listener does not answer to is ignored here, before anything
|
|
2624
|
+
// reads a sender, because a bot that accepted it would be one an operator
|
|
2625
|
+
// could not scope by conversation.
|
|
2481
2626
|
if (chatId !== this.chatId) {
|
|
2482
2627
|
await this.ignore(result, callbackId, "foreign-chat", `callback from chat ${JSON.stringify(chatId)}, which is not the configured approver chat`, "This bot only accepts decisions from its configured approval chat.");
|
|
2483
2628
|
return;
|
|
2484
2629
|
}
|
|
2630
|
+
// APRV-324. The one field on this update that identifies a PERSON rather
|
|
2631
|
+
// than a conversation, read once, here, directly after the chat check and
|
|
2632
|
+
// BEFORE every branch below. Its position is the fix for the follow-up
|
|
2633
|
+
// review: read later, three callback families — checkpoint signatures,
|
|
2634
|
+
// review cards and the decision ladder — would each have had their own
|
|
2635
|
+
// chance to keep the pre-mapping behaviour, and two of them are privileged
|
|
2636
|
+
// gestures. `callback_query.from` is the Bot API's own attribution of the
|
|
2637
|
+
// tap to an account; nothing inside `message`, `data` or any text is
|
|
2638
|
+
// consulted, because those are what the sender says about themselves. It is
|
|
2639
|
+
// an observation and nothing more: this channel resolves no identity and
|
|
2640
|
+
// chooses no actor, it reports what it saw and the runtime's handlers
|
|
2641
|
+
// resolve it against the attested policy.
|
|
2642
|
+
const sender = senderOf(query, this.name);
|
|
2485
2643
|
// APRV-257, before the decision vocabulary and in a parser of its own. A
|
|
2486
2644
|
// checkpoint button decides no request, so it must never reach the ladder
|
|
2487
2645
|
// below — where an unresolved nonce falls back to an action reference, and
|
|
2488
2646
|
// a signature gesture would start looking for something to approve.
|
|
2489
2647
|
const checkpoint = parseCheckpointCallback(query["data"]);
|
|
2490
2648
|
if (checkpoint !== null) {
|
|
2491
|
-
await this.handleCheckpointTap(checkpoint, callbackId, result);
|
|
2649
|
+
await this.handleCheckpointTap(checkpoint, callbackId, result, sender);
|
|
2492
2650
|
return;
|
|
2493
2651
|
}
|
|
2494
2652
|
// APRV-299, before the decision vocabulary and in a parser of its own, for
|
|
@@ -2498,7 +2656,7 @@ export class TelegramChannel {
|
|
|
2498
2656
|
// happened would start looking for something to approve.
|
|
2499
2657
|
const review = parseReviewCallback(query["data"]);
|
|
2500
2658
|
if (review !== null) {
|
|
2501
|
-
await this.handleReviewTap(review, callbackId, result);
|
|
2659
|
+
await this.handleReviewTap(review, callbackId, result, sender);
|
|
2502
2660
|
return;
|
|
2503
2661
|
}
|
|
2504
2662
|
const parsed = parseCallbackData(query["data"]);
|
|
@@ -2510,7 +2668,7 @@ export class TelegramChannel {
|
|
|
2510
2668
|
// decides is whatever is still open on that delivery right now, which this
|
|
2511
2669
|
// process knows and the callback bytes deliberately do not say.
|
|
2512
2670
|
if (parsed.scope === "all") {
|
|
2513
|
-
await this.handleDigestAll(parsed.decision, parsed.nonce, callbackId, result);
|
|
2671
|
+
await this.handleDigestAll(parsed.decision, parsed.nonce, callbackId, result, sender);
|
|
2514
2672
|
return;
|
|
2515
2673
|
}
|
|
2516
2674
|
// The resolution ladder (APRV-196). A tap is answered by the nonce when
|
|
@@ -2552,6 +2710,7 @@ export class TelegramChannel {
|
|
|
2552
2710
|
action_key: delivery.actionKey,
|
|
2553
2711
|
decision: parsed.decision,
|
|
2554
2712
|
deliveryId: delivery.deliveryId,
|
|
2713
|
+
...(sender === undefined ? {} : { sender }),
|
|
2555
2714
|
...(delivery.batchDeliveryId === undefined
|
|
2556
2715
|
? {}
|
|
2557
2716
|
: { batchDeliveryId: delivery.batchDeliveryId }),
|
|
@@ -2643,7 +2802,11 @@ export class TelegramChannel {
|
|
|
2643
2802
|
* the same message would show the approver their own decisions arriving one
|
|
2644
2803
|
* at a time, and would spend N Bot API calls to end in the same place.
|
|
2645
2804
|
*/
|
|
2646
|
-
async handleDigestAll(decision, nonce, callbackId, result
|
|
2805
|
+
async handleDigestAll(decision, nonce, callbackId, result,
|
|
2806
|
+
// APRV-324: one gesture, one sender, on every member it decides. An "all"
|
|
2807
|
+
// tap is N decisions by one person, so each of the N carries the account
|
|
2808
|
+
// the one tap came from.
|
|
2809
|
+
sender) {
|
|
2647
2810
|
const deliveryId = this.allNonces.get(nonce);
|
|
2648
2811
|
const digest = deliveryId === undefined ? undefined : this.digests.get(deliveryId);
|
|
2649
2812
|
if (digest === undefined) {
|
|
@@ -2667,6 +2830,7 @@ export class TelegramChannel {
|
|
|
2667
2830
|
decision,
|
|
2668
2831
|
deliveryId: digest.deliveryId,
|
|
2669
2832
|
batchDeliveryId: digest.batchDeliveryId,
|
|
2833
|
+
...(sender === undefined ? {} : { sender }),
|
|
2670
2834
|
...(decision === "reject"
|
|
2671
2835
|
? { note: `${TELEGRAM_REJECT_NOTE} (callback ${callbackId}, all)` }
|
|
2672
2836
|
: {}),
|
|
@@ -2771,7 +2935,11 @@ export class TelegramChannel {
|
|
|
2771
2935
|
* and appends to a log, and a spinner that lasted a decision long is what
|
|
2772
2936
|
* that task removed.
|
|
2773
2937
|
*/
|
|
2774
|
-
async handleCheckpointTap(tap, callbackId, result
|
|
2938
|
+
async handleCheckpointTap(tap, callbackId, result,
|
|
2939
|
+
// APRV-324 follow-up. A checkpoint signature is a human-only act and says
|
|
2940
|
+
// this log's head is what this person saw, so it carries the account the
|
|
2941
|
+
// tap came from exactly as a decision does.
|
|
2942
|
+
sender) {
|
|
2775
2943
|
const held = this.checkpointNonces.get(tap.nonce);
|
|
2776
2944
|
if (held === undefined) {
|
|
2777
2945
|
await this.ignore(result, callbackId, "unknown-callback", `checkpoint nonce ${JSON.stringify(tap.nonce)} was not issued by this process`, "This checkpoint prompt is from an earlier run. A fresh one is offered when the next is due.");
|
|
@@ -2784,7 +2952,11 @@ export class TelegramChannel {
|
|
|
2784
2952
|
return;
|
|
2785
2953
|
}
|
|
2786
2954
|
await this.safeAnswer(callbackId, tap.sign ? "Heard — signing. The message will say what the log recorded." : "Not now.");
|
|
2787
|
-
const response = await handler({
|
|
2955
|
+
const response = await handler({
|
|
2956
|
+
sign: tap.sign,
|
|
2957
|
+
head: held.head,
|
|
2958
|
+
...(sender === undefined ? {} : { sender }),
|
|
2959
|
+
});
|
|
2788
2960
|
// Edited here rather than through `annotate`, which renders an action key
|
|
2789
2961
|
// under its headline. A checkpoint has none, and an empty `<code></code>`
|
|
2790
2962
|
// where a request's key belongs would be this channel implying a request.
|
|
@@ -2817,7 +2989,7 @@ export class TelegramChannel {
|
|
|
2817
2989
|
* re-implement the rule. The one thing this method owns is the ARMING, which
|
|
2818
2990
|
* is process memory that appends nothing.
|
|
2819
2991
|
*/
|
|
2820
|
-
async handleReviewTap(tap, callbackId, result) {
|
|
2992
|
+
async handleReviewTap(tap, callbackId, result, sender) {
|
|
2821
2993
|
const deliveryId = this.reviewNonces.get(tap.nonce);
|
|
2822
2994
|
const state = deliveryId === undefined ? undefined : this.reviewCards.get(deliveryId);
|
|
2823
2995
|
if (state === undefined || state.settled !== null) {
|
|
@@ -2845,7 +3017,11 @@ export class TelegramChannel {
|
|
|
2845
3017
|
const chosen = tap.choice === "deny" ? "denied" : "ok";
|
|
2846
3018
|
state.denyArmed = chosen === "denied";
|
|
2847
3019
|
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
|
|
2848
|
-
await this.recordReview(state, {
|
|
3020
|
+
await this.recordReview(state, {
|
|
3021
|
+
sampleSeq: state.card.sampleSeq,
|
|
3022
|
+
verdict: chosen,
|
|
3023
|
+
...(sender === undefined ? {} : { sender }),
|
|
3024
|
+
}, result);
|
|
2849
3025
|
return;
|
|
2850
3026
|
}
|
|
2851
3027
|
const reaction = tap.choice;
|
|
@@ -2858,11 +3034,16 @@ export class TelegramChannel {
|
|
|
2858
3034
|
const conflicts = verdict === "denied" && (reaction === "liked" || reaction === "loved");
|
|
2859
3035
|
if (!conflicts && (reaction === "loved" || reaction === "disliked")) {
|
|
2860
3036
|
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_NOTE_TOAST);
|
|
2861
|
-
await this.askForNote(state, verdict, reaction);
|
|
3037
|
+
await this.askForNote(state, verdict, reaction, sender);
|
|
2862
3038
|
return;
|
|
2863
3039
|
}
|
|
2864
3040
|
await this.safeAnswer(callbackId, TELEGRAM_REVIEW_ACK);
|
|
2865
|
-
await this.recordReview(state, {
|
|
3041
|
+
await this.recordReview(state, {
|
|
3042
|
+
sampleSeq: state.card.sampleSeq,
|
|
3043
|
+
verdict,
|
|
3044
|
+
reaction,
|
|
3045
|
+
...(sender === undefined ? {} : { sender }),
|
|
3046
|
+
}, result);
|
|
2866
3047
|
}
|
|
2867
3048
|
/**
|
|
2868
3049
|
* Send the ForceReply prompt a `loved` or `disliked` needs, and remember what
|
|
@@ -2879,7 +3060,7 @@ export class TelegramChannel {
|
|
|
2879
3060
|
* one card, and the older prompt stops resolving so a late reply to it lands
|
|
2880
3061
|
* nowhere rather than recording a grade the human moved on from.
|
|
2881
3062
|
*/
|
|
2882
|
-
async askForNote(state, verdict, reaction) {
|
|
3063
|
+
async askForNote(state, verdict, reaction, sender) {
|
|
2883
3064
|
if (state.awaitingNote !== null)
|
|
2884
3065
|
this.reviewNotePrompts.delete(state.awaitingNote.promptId);
|
|
2885
3066
|
const lines = reviewNotePromptLines(reaction, verdict, state.card.fields.action_key.value);
|
|
@@ -2893,7 +3074,7 @@ export class TelegramChannel {
|
|
|
2893
3074
|
reply_markup: { force_reply: true },
|
|
2894
3075
|
});
|
|
2895
3076
|
const promptId = String(sent.message_id);
|
|
2896
|
-
state.awaitingNote = { promptId, verdict, reaction };
|
|
3077
|
+
state.awaitingNote = { promptId, verdict, reaction, ...(sender === undefined ? {} : { sender }) };
|
|
2897
3078
|
this.reviewNotePrompts.set(promptId, state.deliveryId);
|
|
2898
3079
|
}
|
|
2899
3080
|
/**
|
|
@@ -2928,6 +3109,19 @@ export class TelegramChannel {
|
|
|
2928
3109
|
}
|
|
2929
3110
|
const state = this.reviewCards.get(deliveryId);
|
|
2930
3111
|
const pending = state?.awaitingNote ?? null;
|
|
3112
|
+
// APRV-324 follow-up, and checked BEFORE the prompt is forgotten, so a
|
|
3113
|
+
// reply from the wrong account costs the right one nothing. A note prompt
|
|
3114
|
+
// is a ForceReply addressed to the person who armed the grade, and its
|
|
3115
|
+
// words are recorded as that person's; a different account replying to it
|
|
3116
|
+
// is answering a question nobody asked them.
|
|
3117
|
+
const sender = senderOf(message, this.name);
|
|
3118
|
+
if (pending !== null &&
|
|
3119
|
+
pending.promptId === promptId &&
|
|
3120
|
+
pending.sender !== undefined &&
|
|
3121
|
+
(sender === undefined || sender.id !== pending.sender.id)) {
|
|
3122
|
+
this.complain("approval: telegram ignored a review note: it replied to a prompt issued for a different account, and the prompt is still open for the account that armed it");
|
|
3123
|
+
return true;
|
|
3124
|
+
}
|
|
2931
3125
|
this.reviewNotePrompts.delete(promptId);
|
|
2932
3126
|
if (state === undefined || pending === null || pending.promptId !== promptId)
|
|
2933
3127
|
return true;
|
|
@@ -2937,6 +3131,7 @@ export class TelegramChannel {
|
|
|
2937
3131
|
verdict: pending.verdict,
|
|
2938
3132
|
reaction: pending.reaction,
|
|
2939
3133
|
note: text,
|
|
3134
|
+
...(sender === undefined ? {} : { sender }),
|
|
2940
3135
|
}, result);
|
|
2941
3136
|
return true;
|
|
2942
3137
|
}
|