approval-md 0.2.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.
- package/README.md +85 -877
- package/SPEC.md +150 -19
- package/cli.js +66 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +53 -1
- package/dist/src/channels/contract.js +304 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.js +6 -1
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +394 -12
- package/dist/src/channels/telegram.js +621 -68
- 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 +564 -34
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.d.ts +9 -0
- package/dist/src/cli/attest.js +177 -13
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
- package/dist/src/cli/channel-telegram-webhook.js +709 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +347 -28
- package/dist/src/cli/channel-telegram.js +763 -28
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +10 -1
- package/dist/src/cli/channel.js +14 -1
- 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 +41 -6
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +1322 -122
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +17 -1
- package/dist/src/cli/execute.js +366 -22
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +14 -2
- package/dist/src/cli/help.js +395 -82
- 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 +828 -4
- package/dist/src/cli/hook.js +2972 -142
- 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/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +56 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.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 +7 -1
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- 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 +134 -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.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +510 -47
- 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-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +234 -2
- package/dist/src/core/attest.js +342 -7
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +73 -3
- package/dist/src/core/audit.js +65 -5
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- 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 +205 -0
- package/dist/src/core/command-class.js +1433 -28
- 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/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.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 +392 -6
- package/dist/src/core/gate.js +852 -24
- 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 +128 -4
- package/dist/src/core/harness-version.js +181 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.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-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +89 -22
- package/dist/src/core/log.js +197 -19
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +17 -1
- package/dist/src/core/policy-explain.js +48 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +65 -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 +81 -0
- package/dist/src/core/policy-match.js +87 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +75 -0
- package/dist/src/core/policy-proposal.js +110 -4
- 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 +182 -0
- package/dist/src/core/read-scope.js +256 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.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 +501 -0
- package/dist/src/core/sender-identity.js +609 -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/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- 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/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.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/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +307 -7
- 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/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +30 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +158 -0
- package/dist/src/serve/archive.js +354 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +766 -0
- package/docs/cli-reference.md +2563 -73
- package/docs/codex-enforced-session.md +75 -2
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +6 -2
- package/schema/event.schema.json +595 -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/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -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/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -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/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +15 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -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-hermes.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -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/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +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 +100 -4
- package/schema/values.schema.json +7 -11
- package/schema/fixtures/values/invalid/version-string.json +0 -1
|
@@ -14,12 +14,18 @@
|
|
|
14
14
|
* are read here and passed to the channel as values (SPEC.md §5.1: policy
|
|
15
15
|
* carries the env-var *names*, never the secrets). Nothing in `channels/`
|
|
16
16
|
* touches `process.env`.
|
|
17
|
-
* 2. **It declares
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* configured chat can approve as that
|
|
22
|
-
*
|
|
17
|
+
* 2. **It declares the identity the process itself acts under.** `--as` /
|
|
18
|
+
* `APPROVAL_HUMAN` is what this listener is, and it is what a decision is
|
|
19
|
+
* recorded as whenever the policy maps no sender for this channel: SPEC.md
|
|
20
|
+
* §11's config-declared identity, where the trust boundary is the local
|
|
21
|
+
* machine and everyone who can reach the configured chat can approve as that
|
|
22
|
+
* actor. Since APRV-324 a policy MAY map Telegram account ids to approvers,
|
|
23
|
+
* and then the decision is recorded against the person the operator attested
|
|
24
|
+
* the tapping account to, and an unmapped account is refused rather than
|
|
25
|
+
* recorded as this listener. Either way the choosing happens in
|
|
26
|
+
* `channels/contract.ts` against the attested policy, never here and never
|
|
27
|
+
* in the channel. Both settings are stated in `--help` because an operator
|
|
28
|
+
* has to be able to see which one they are in without reading the source.
|
|
23
29
|
* 3. **It holds the token.** A grant mints a single-use execution token;
|
|
24
30
|
* `recordChannelDecision` returns it to *this* handler, which prints it on
|
|
25
31
|
* **stdout** and never hands it back to the channel. It is never sent to
|
|
@@ -104,16 +110,24 @@
|
|
|
104
110
|
import { readFileSync, statSync } from "node:fs";
|
|
105
111
|
import { isAbsolute, resolve as resolvePathSegments } from "node:path";
|
|
106
112
|
import { HUMAN_ACTOR_ENV, resolveHumanActor } from "../core/attest.js";
|
|
113
|
+
import { isProposalTask } from "../core/gate.js";
|
|
107
114
|
import { assembleBatch } from "../channels/batch.js";
|
|
108
|
-
import { recordChannelDecision, } from "../channels/contract.js";
|
|
115
|
+
import { recordChannelDecision, refusedDecisionLine, } from "../channels/contract.js";
|
|
109
116
|
import { ageText, buildPendingQueue, } from "../channels/tagging.js";
|
|
110
117
|
import { checkpointOfferFor, checkpointPromptLines, checkpointSignedLines, signCheckpointOffer, } from "./checkpoint-tap.js";
|
|
111
|
-
import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTelegramTerminalState, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
|
|
118
|
+
import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTelegramTerminalState, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
|
|
112
119
|
import { openReviewCards } from "./audit-card.js";
|
|
113
120
|
import { reviewSample } from "../core/audit.js";
|
|
114
121
|
import { abandonedAfterMs, HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
115
|
-
import { telegramDeliveryFor } from "../core/telegram-config.js";
|
|
122
|
+
import { normaliseWebhookUrl, redactWebhookUrl, telegramDeliveryFor, } from "../core/telegram-config.js";
|
|
123
|
+
import { reclaimDetail, takeChannelLease, } from "../core/channel-lease.js";
|
|
116
124
|
import { loadPolicy } from "../core/policy-load.js";
|
|
125
|
+
import { valueFindings } from "../core/instance.js";
|
|
126
|
+
import { defaultSourceRunner } from "../core/env-file.js";
|
|
127
|
+
import { claimBot, describeOwners, otherOwnersOf, ownedBot, } from "../core/channel-owner.js";
|
|
128
|
+
import { actorForSender, recordedSenderFor, senderKeyFrom, } from "../core/sender-identity.js";
|
|
129
|
+
import { recordRefusedGesture, } from "../core/gesture-refusal.js";
|
|
130
|
+
import { attestationRefusal, checkAttestation } from "../core/attest.js";
|
|
117
131
|
import { promptLayoutFor } from "../core/prompt-layout.js";
|
|
118
132
|
import { passphraseEnvFor } from "../core/vault.js";
|
|
119
133
|
import { isAttestationActionKey, proposalRecords, proposalState, } from "../core/policy-proposal.js";
|
|
@@ -136,6 +150,8 @@ const LISTEN_FLAGS = {
|
|
|
136
150
|
"--api-base": "string",
|
|
137
151
|
"--poll-timeout": "string",
|
|
138
152
|
"--once": "boolean",
|
|
153
|
+
/** APRV-390: start on a credential this instance did not configure. */
|
|
154
|
+
"--allow-cross-instance": "boolean",
|
|
139
155
|
"--gloss": "boolean",
|
|
140
156
|
"--no-gloss": "boolean",
|
|
141
157
|
"--gloss-provider": "string",
|
|
@@ -201,6 +217,215 @@ function env(name) {
|
|
|
201
217
|
const value = process.env[name];
|
|
202
218
|
return value === undefined || value.trim().length === 0 ? null : value.trim();
|
|
203
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Claim this listener's bot before it polls (APRV-390, extended by APRV-424).
|
|
222
|
+
*
|
|
223
|
+
* Three questions, in order, and each one has its own authority:
|
|
224
|
+
*
|
|
225
|
+
* 1. **Does anything else in THIS gate already hold the Telegram transport?**
|
|
226
|
+
* A lockfile in the gate's own `daemon/` directory (`core/channel-lease.ts`)
|
|
227
|
+
* answers it, and the answer is per PROCESS. This is the check the review of
|
|
228
|
+
* APRV-424 added, and the hole it closes is the one neither of the other two
|
|
229
|
+
* could see: an `approval up` long-polling and an `approval channel telegram
|
|
230
|
+
* webhook` started in the same project pass both of them — `getWebhookInfo`
|
|
231
|
+
* is empty while the poller is the one running, and the ownership registry
|
|
232
|
+
* compares instance ids, which are equal because it IS the same instance —
|
|
233
|
+
* and then each runs its own dispatch cycle over its own state against one
|
|
234
|
+
* log, so every request reaches the phone twice under two nonces and only
|
|
235
|
+
* one copy can resolve a tap.
|
|
236
|
+
* 2. **What is the Bot API delivering this bot's updates to?** `getWebhookInfo`
|
|
237
|
+
* answers it, and it is the only thing that can see a webhook registered
|
|
238
|
+
* from another machine. A webhook beats a poller outright — `getUpdates` is
|
|
239
|
+
* refused while one is set — so any registration refuses a poller.
|
|
240
|
+
* 3. **Does another INSTANCE on this machine hold this bot?** `getMe` plus the
|
|
241
|
+
* per-machine registry answers it (APRV-390's incident: two gates, one
|
|
242
|
+
* token, two names, a 409 on every poll).
|
|
243
|
+
*
|
|
244
|
+
* ## An unreachable Bot API: fail-soft for a poller, fail-closed for a webhook
|
|
245
|
+
*
|
|
246
|
+
* A `getMe` or a `getWebhookInfo` that fails says nothing about ownership. The
|
|
247
|
+
* machine may be behind a captive portal, the API may be rate-limiting, the
|
|
248
|
+
* network may be down for four seconds. For a POLLER, refusing on that would
|
|
249
|
+
* mean a transient network fault took the phone channel down until somebody
|
|
250
|
+
* noticed, in exchange for no safety: an unreachable Bot API is also a
|
|
251
|
+
* `getUpdates` that cannot conflict with anything, and the HTTP 409 the loop
|
|
252
|
+
* already reports is the backstop. So the poller says what happened and starts.
|
|
253
|
+
*
|
|
254
|
+
* For the WEBHOOK VERB there is no such backstop, and the review of APRV-424
|
|
255
|
+
* turned this direction around. Its next act is `setWebhook`, which overwrites
|
|
256
|
+
* whatever registration exists without asking, so proceeding on an unknown
|
|
257
|
+
* answer is proceeding to take another host's taps with no error anywhere. A
|
|
258
|
+
* probe it could not make is therefore a refusal (`webhook-probe-failed`), and
|
|
259
|
+
* the operator retries or passes `--reclaim` deliberately.
|
|
260
|
+
*/
|
|
261
|
+
export async function claimListenerBot(setup, report, options = {}) {
|
|
262
|
+
const mode = options.mode ?? (options.webhookUrl === undefined ? "poll" : "webhook");
|
|
263
|
+
// The local, deterministic question first: it needs no network, and a
|
|
264
|
+
// refusal here is one an operator can act on without waiting for a round
|
|
265
|
+
// trip. Released again below if a later check refuses, so a refused start
|
|
266
|
+
// never leaves a lease behind for the next one to reclaim.
|
|
267
|
+
const leased = takeChannelLease(setup.logPath, mode, {
|
|
268
|
+
note: (message) => report(`approval: ${message}`),
|
|
269
|
+
});
|
|
270
|
+
if (!leased.ok) {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
code: leased.code,
|
|
274
|
+
message: leased.message,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (leased.reclaimed !== null && leased.reclaimedBecause !== null) {
|
|
278
|
+
report(`approval: ${reclaimDetail(leased.reclaimed, leased.reclaimedBecause)}`);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Did THIS gate's own webhook runner die without removing its registration?
|
|
282
|
+
*
|
|
283
|
+
* The evidence is the lease this take just reclaimed: a record in this
|
|
284
|
+
* gate's own lockfile, written by a webhook runner, whose process is no
|
|
285
|
+
* longer the one holding it. A SIGKILL leaves exactly that, plus a
|
|
286
|
+
* registration at the url the dead process had registered, and without this
|
|
287
|
+
* every restart would refuse `webhook-registered` until an operator baked
|
|
288
|
+
* `--reclaim` into the unit file — which would retire finding 6's protection
|
|
289
|
+
* for good, in exchange for a crash recovery.
|
|
290
|
+
*
|
|
291
|
+
* It is deliberately narrow. Same gate (it is the same lockfile), same
|
|
292
|
+
* transport (a dead poller proves nothing about a registration), the process
|
|
293
|
+
* demonstrably GONE, and the url still has to match after normalisation.
|
|
294
|
+
* Anything else keeps refusing.
|
|
295
|
+
*
|
|
296
|
+
* "Gone" is the only reclaim reason that counts (third review, finding 2).
|
|
297
|
+
* The other two are reclaims on a judgement about a NUMBER rather than a
|
|
298
|
+
* death: `foreign` means a pid this process cannot signal, and `recycled`
|
|
299
|
+
* means a pid whose process started after the lease was written. Both are
|
|
300
|
+
* the right call for the lease, which only has to stop being held; neither
|
|
301
|
+
* is evidence that the webhook runner that registered this url has stopped
|
|
302
|
+
* running, and on a shared machine a foreign pid is as likely to be the
|
|
303
|
+
* OTHER host's live runner. So they keep the refusal and the `--reclaim`
|
|
304
|
+
* demand.
|
|
305
|
+
*/
|
|
306
|
+
const ownWebhookDied = leased.reclaimed !== null &&
|
|
307
|
+
leased.reclaimed.mode === "webhook" &&
|
|
308
|
+
leased.reclaimedBecause === "gone";
|
|
309
|
+
const release = () => leased.lease.release();
|
|
310
|
+
const refuse = (code, message) => {
|
|
311
|
+
release();
|
|
312
|
+
return { ok: false, code, message };
|
|
313
|
+
};
|
|
314
|
+
let identity;
|
|
315
|
+
try {
|
|
316
|
+
identity = await setup.channel.identify();
|
|
317
|
+
}
|
|
318
|
+
catch (cause) {
|
|
319
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
320
|
+
if (mode === "webhook") {
|
|
321
|
+
// Fail closed: see the module note above. `setWebhook` would overwrite a
|
|
322
|
+
// registration this process could not see.
|
|
323
|
+
return refuse("webhook-probe-failed", `telegram getMe could not be reached (${detail}), so which bot this token names and what is already receiving its updates are both unknown. This verb registers a webhook, and setWebhook overwrites whatever is registered without reporting it, so it does not run on an unknown answer: fix the connection and start again, or pass --reclaim to take the bot deliberately`);
|
|
324
|
+
}
|
|
325
|
+
report(`approval: telegram getMe could not be reached (${detail}), so which bot this token names is unknown and no ownership was recorded; starting anyway`);
|
|
326
|
+
return { ok: true, lease: leased.lease };
|
|
327
|
+
}
|
|
328
|
+
if (identity.id.length === 0) {
|
|
329
|
+
if (mode === "webhook") {
|
|
330
|
+
return refuse("webhook-probe-failed", "telegram getMe answered without a bot id, so which bot this token names is unknown. This verb registers a webhook over whatever is already registered, and will not do that on an unknown answer");
|
|
331
|
+
}
|
|
332
|
+
report("approval: telegram getMe answered without a bot id, so no ownership was recorded; starting anyway");
|
|
333
|
+
return { ok: true, lease: leased.lease };
|
|
334
|
+
}
|
|
335
|
+
// APRV-424, and before the ownership claim: the registry answers "which
|
|
336
|
+
// process on THIS machine holds this bot", and the Bot API answers "what is
|
|
337
|
+
// this bot's updates going to at all". A webhook beats both — `getUpdates`
|
|
338
|
+
// is refused while one is set — so a listener started here would receive
|
|
339
|
+
// nothing whoever else is or is not running locally.
|
|
340
|
+
const hooked = await webhookOwner(setup);
|
|
341
|
+
if (hooked.state === "unknown") {
|
|
342
|
+
if (mode === "webhook") {
|
|
343
|
+
return refuse("webhook-probe-failed", `telegram getWebhookInfo could not be reached (${hooked.detail}), so whether another host's webhook holds this bot is unknown. setWebhook would overwrite it without saying so, and the displaced receiver would simply stop being posted to, so this verb refuses rather than guessing: try again, or pass --reclaim to register over whatever is there`);
|
|
344
|
+
}
|
|
345
|
+
// The poller's documented fail-soft (see the module note): what a missed
|
|
346
|
+
// probe costs is the clear refusal, and the 409 path is still there.
|
|
347
|
+
report(`approval: telegram getWebhookInfo could not be reached (${hooked.detail}), so whether a webhook holds this bot is unknown; starting anyway`);
|
|
348
|
+
}
|
|
349
|
+
if (hooked.state === "registered") {
|
|
350
|
+
const queued = hooked.pendingUpdateCount === 0
|
|
351
|
+
? ""
|
|
352
|
+
: ` (${String(hooked.pendingUpdateCount)} update(s) are queued for it)`;
|
|
353
|
+
// Origin plus a redacted path, never the url as the Bot API spelled it:
|
|
354
|
+
// this is somebody else's registration, its path may itself be a bearer
|
|
355
|
+
// value (review finding 10), and the origin is what tells hosts apart.
|
|
356
|
+
const shown = redactWebhookUrl(hooked.url);
|
|
357
|
+
if (options.webhookUrl === undefined) {
|
|
358
|
+
return refuse("webhook-registered", `telegram ${identity.username} delivers its updates to a webhook at ${shown}, and the Bot API refuses getUpdates while one is set, so this listener would receive no tap at all${queued}. Long polling and a webhook are alternatives per bot: stop the \`approval channel telegram webhook\` process, which removes the webhook as it exits, or give this instance its own bot with \`approval setup channel telegram\``);
|
|
359
|
+
}
|
|
360
|
+
const mine = normaliseWebhookUrl(options.webhookUrl);
|
|
361
|
+
const theirs = normaliseWebhookUrl(hooked.url);
|
|
362
|
+
const same = mine !== null && theirs !== null && mine === theirs;
|
|
363
|
+
if (same && ownWebhookDied) {
|
|
364
|
+
// The restart case, and the only one that needs no flag: this gate's own
|
|
365
|
+
// webhook runner was killed without the chance to call `deleteWebhook`,
|
|
366
|
+
// and what is registered is the url it left behind.
|
|
367
|
+
report(`approval: the webhook at ${shown} is the one this gate's own runner registered before it died (its lease was reclaimed from pid ${String(leased.reclaimed?.pid ?? 0)}); re-registering the same url, which needs no --reclaim`);
|
|
368
|
+
}
|
|
369
|
+
else if (options.reclaim !== true) {
|
|
370
|
+
return refuse("webhook-registered", `telegram ${identity.username} already delivers its updates to a webhook at ${shown}${queued}. ${same
|
|
371
|
+
? "That is the url this process was asked to register, which looks like a restart and may not be one: two hosts running one configuration both read this as their own, the second overwrites the first's secret_token, and from then on every real tap reaches the first host as a forgery"
|
|
372
|
+
: "This process was asked to register a different url, and a bot has exactly one webhook: registering would silently take the taps away from whatever is serving that one"}. Telegram allows one webhook per bot and keeps the last registration. Stop the other process, or pass --reclaim to register over it deliberately`);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
report(same
|
|
376
|
+
? `approval: --reclaim: re-registering ${shown}, which is the url already registered for ${identity.username}`
|
|
377
|
+
: `approval: --reclaim: registering over the webhook at ${shown}; whatever is serving it stops being posted to, because Telegram keeps only the last registration for ${identity.username}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const claim = claimBot(setup.logPath, {
|
|
381
|
+
channel: "telegram",
|
|
382
|
+
botId: identity.id,
|
|
383
|
+
username: identity.username,
|
|
384
|
+
apiBase: setup.apiBase,
|
|
385
|
+
});
|
|
386
|
+
if (!claim.ok) {
|
|
387
|
+
if (!setup.allowCrossInstance) {
|
|
388
|
+
return refuse("bot-owned-elsewhere", claim.message);
|
|
389
|
+
}
|
|
390
|
+
// The deliberate case. It says what it is doing, and it does NOT record a
|
|
391
|
+
// second claim: the registry answers "who owns this bot", and two owners
|
|
392
|
+
// is the state it exists to report rather than a state to write down.
|
|
393
|
+
report(`approval: --allow-cross-instance: starting anyway — ${claim.message}`);
|
|
394
|
+
return { ok: true, lease: leased.lease };
|
|
395
|
+
}
|
|
396
|
+
report(`approval: telegram ${identity.username} (bot id ${identity.id}) is this instance's own; no update was consumed by this check`);
|
|
397
|
+
return { ok: true, lease: leased.lease };
|
|
398
|
+
}
|
|
399
|
+
async function webhookOwner(setup) {
|
|
400
|
+
let info;
|
|
401
|
+
try {
|
|
402
|
+
info = await setup.channel.webhookInfo();
|
|
403
|
+
}
|
|
404
|
+
catch (cause) {
|
|
405
|
+
return { state: "unknown", detail: cause instanceof Error ? cause.message : String(cause) };
|
|
406
|
+
}
|
|
407
|
+
return info.url.length === 0
|
|
408
|
+
? { state: "none" }
|
|
409
|
+
: { state: "registered", url: info.url, pendingUpdateCount: info.pendingUpdateCount };
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* What to append to a runtime 409, naming the other instance if one is known.
|
|
413
|
+
*
|
|
414
|
+
* Read at the moment of the conflict rather than captured at start-up, because
|
|
415
|
+
* the other gate may have claimed the bot in between: the registry is the only
|
|
416
|
+
* thing that knows, and it is cheap to re-read.
|
|
417
|
+
*/
|
|
418
|
+
export function conflictAdviceFor(setup) {
|
|
419
|
+
return () => {
|
|
420
|
+
const mine = ownedBot(setup.logPath, "telegram");
|
|
421
|
+
if (mine === null)
|
|
422
|
+
return null;
|
|
423
|
+
const others = otherOwnersOf(setup.logPath, mine.botId, mine.apiBase);
|
|
424
|
+
return others.length === 0
|
|
425
|
+
? `this instance is ${mine.instanceHome} (instance ${mine.instanceId}) on ${mine.username}, and nothing else on this machine has claimed it — the other poller is on another machine, or was started outside this runtime`
|
|
426
|
+
: `${mine.instanceHome} (instance ${mine.instanceId}) and ${describeOwners(others)} have both claimed ${mine.username}; stop one of them`;
|
|
427
|
+
};
|
|
428
|
+
}
|
|
204
429
|
function payloadSource(resolved) {
|
|
205
430
|
if (resolved === null)
|
|
206
431
|
return { ok: true, source: undefined };
|
|
@@ -243,6 +468,69 @@ export const LISTEN_REFUSAL_CODES = [
|
|
|
243
468
|
"log-unreadable",
|
|
244
469
|
/** `--payloads` did not hold a JSON object of action key -> payload. */
|
|
245
470
|
"payloads-unreadable",
|
|
471
|
+
/**
|
|
472
|
+
* A credential variable holds a value this instance did not configure
|
|
473
|
+
* (APRV-390): it was exported before the process started and is not this
|
|
474
|
+
* instance's own `approval env` export, or `.approval/env` names another
|
|
475
|
+
* instance's keystore item. Refused rather than warned since APRV-390 — the
|
|
476
|
+
* warning is what let a demo gate spend an evening on the production bot.
|
|
477
|
+
* `--allow-cross-instance` is the deliberate case.
|
|
478
|
+
*/
|
|
479
|
+
"cross-instance-credential",
|
|
480
|
+
/**
|
|
481
|
+
* `getMe` named a bot another instance on this machine has already claimed
|
|
482
|
+
* (APRV-390). Refused BEFORE the first `getUpdates`, so the operator reads
|
|
483
|
+
* which two gates are involved instead of an HTTP 409 loop.
|
|
484
|
+
*/
|
|
485
|
+
"bot-owned-elsewhere",
|
|
486
|
+
/**
|
|
487
|
+
* This bot's updates are being delivered to a webhook (APRV-424).
|
|
488
|
+
*
|
|
489
|
+
* The two transports are alternatives per bot: the Bot API refuses
|
|
490
|
+
* `getUpdates` outright while a webhook is set, so a listener that started
|
|
491
|
+
* here would print a 409 every few seconds and receive not one tap. Refused
|
|
492
|
+
* BEFORE the first poll, in the same preflight and for the same reason
|
|
493
|
+
* `bot-owned-elsewhere` is, so the operator reads which transport owns the
|
|
494
|
+
* bot instead of a status code.
|
|
495
|
+
*
|
|
496
|
+
* Since the review of APRV-424 it carries a second, local source: the
|
|
497
|
+
* transport lease in this gate's own `daemon/` directory, when the process
|
|
498
|
+
* holding it is a webhook runner. Same fact, same repair, so the same code.
|
|
499
|
+
*/
|
|
500
|
+
"webhook-registered",
|
|
501
|
+
/**
|
|
502
|
+
* A long-poll listener in THIS gate already holds the Telegram transport
|
|
503
|
+
* (APRV-424 review finding 1).
|
|
504
|
+
*
|
|
505
|
+
* The counterpart to `webhook-registered`, from the same lease. It is what
|
|
506
|
+
* the webhook verb meets when an `approval up` or an `approval channel
|
|
507
|
+
* telegram listen` is already running in the same project — the case
|
|
508
|
+
* `getWebhookInfo` cannot see (nothing is registered yet) and the ownership
|
|
509
|
+
* registry cannot see either (it is the same instance). Two of them on one
|
|
510
|
+
* log put every prompt on the phone twice, under two nonces, and only one
|
|
511
|
+
* copy can resolve a tap.
|
|
512
|
+
*/
|
|
513
|
+
"telegram-poller-running",
|
|
514
|
+
/**
|
|
515
|
+
* The transport lease could not be taken at all (APRV-424 review finding 1).
|
|
516
|
+
*
|
|
517
|
+
* A `daemon/` directory that cannot be created, a lockfile that cannot be
|
|
518
|
+
* written. Unlike the bot registry, whose absence is the status quo, this
|
|
519
|
+
* file is the only thing keeping one gate from running two transports, so a
|
|
520
|
+
* gate that cannot hold one does not start.
|
|
521
|
+
*/
|
|
522
|
+
"telegram-lease-unavailable",
|
|
523
|
+
/**
|
|
524
|
+
* The webhook verb could not learn what already holds this bot (APRV-424
|
|
525
|
+
* review finding 8).
|
|
526
|
+
*
|
|
527
|
+
* `getMe` or `getWebhookInfo` failed, and the next act would have been a
|
|
528
|
+
* `setWebhook` that overwrites a registration nobody saw. A poller keeps its
|
|
529
|
+
* fail-soft on the same failure, because an unreachable Bot API is also a
|
|
530
|
+
* `getUpdates` that cannot conflict and the HTTP 409 loop is its backstop;
|
|
531
|
+
* this verb has no backstop, so it refuses.
|
|
532
|
+
*/
|
|
533
|
+
"webhook-probe-failed",
|
|
246
534
|
];
|
|
247
535
|
/**
|
|
248
536
|
* Everything that can fail without touching the network, in order.
|
|
@@ -280,6 +568,35 @@ export function prepareListen(request) {
|
|
|
280
568
|
message: `telegram is not configured: ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} unset or empty (both ${tokenEnv} and ${chatEnv} are required; APPROVAL.md carries only their names)`,
|
|
281
569
|
};
|
|
282
570
|
}
|
|
571
|
+
// APRV-390. WHOSE credentials are these? `instanceFindings` answers from
|
|
572
|
+
// names alone — no value is read, compared or printed — and until this task
|
|
573
|
+
// its answer was a warning `approval up` printed on the way past. It is a
|
|
574
|
+
// refusal now. The incident it is named after is a demo gate that started on
|
|
575
|
+
// the primary's exported token and long-polled the primary's bot: the
|
|
576
|
+
// warning was on the operator's terminal the whole time, above a process
|
|
577
|
+
// that had already started. An override exists because feeding a daemon from
|
|
578
|
+
// a shell profile is a deliberate, supported thing to do; what was missing
|
|
579
|
+
// is that it now has to be said out loud.
|
|
580
|
+
//
|
|
581
|
+
// `valueFindings` and not `instanceFindings`: this is the one caller that is
|
|
582
|
+
// about to USE the values, so it is allowed to resolve the file and compare
|
|
583
|
+
// (APRV-390, second round). Comparing is what tells a stale export — the
|
|
584
|
+
// shell holding a token the keychain has since replaced, which `approval env`
|
|
585
|
+
// will never override — from an honest one, and what stops the correct `eval
|
|
586
|
+
// "$(approval env)"` ritual being reported as a finding. `approval doctor`
|
|
587
|
+
// keeps the name-only rule, because a diagnostic may not block on an unlock
|
|
588
|
+
// dialog. No value is printed here or anywhere below.
|
|
589
|
+
const crossInstance = valueFindings(request.logPath, policyLoad, defaultSourceRunner);
|
|
590
|
+
if (crossInstance.length > 0 && request.allowCrossInstance !== true) {
|
|
591
|
+
const stale = crossInstance.some((finding) => finding.kind === "stale-export");
|
|
592
|
+
return {
|
|
593
|
+
ok: false,
|
|
594
|
+
code: "cross-instance-credential",
|
|
595
|
+
message: `${crossInstance.map((finding) => finding.detail).join("; ")}. ${stale
|
|
596
|
+
? "A stale export is why a token re-stored in the keystore can still answer 401: `approval env` never overrides a variable this shell has already exported."
|
|
597
|
+
: "Two gates on one bot token both long-poll it, their getUpdates offsets acknowledge each other's updates, and an approval tap is answered by whichever listener asked first."} Fix it with one of: \`unset ${crossInstance.map((finding) => finding.variable).join(" ")}\` and then \`eval "$(approval env)"\` in this shell; a fresh shell that has never exported it; or \`approval setup channel telegram\` to give this instance its own bot. Pass --allow-cross-instance to start anyway`,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
283
600
|
const actor = resolveHumanActor(request.as === null ? {} : { actor: request.as });
|
|
284
601
|
if (actor === null) {
|
|
285
602
|
return {
|
|
@@ -342,6 +659,10 @@ export function prepareListen(request) {
|
|
|
342
659
|
// asks of the policy file, and a policy that failed to load leaves the
|
|
343
660
|
// default (paced) in force rather than a mode nobody chose.
|
|
344
661
|
delivery: telegramDeliveryFor(policyLoad),
|
|
662
|
+
// APRV-390. Empty unless --allow-cross-instance let a finding through.
|
|
663
|
+
crossInstance,
|
|
664
|
+
allowCrossInstance: request.allowCrossInstance === true,
|
|
665
|
+
apiBase: request.apiBase ?? TELEGRAM_DEFAULT_API_BASE,
|
|
345
666
|
gateOptions: { policy: request.policy },
|
|
346
667
|
tagOptions: {
|
|
347
668
|
policy: request.policy,
|
|
@@ -458,6 +779,8 @@ function setUp(argv, streams, cwd) {
|
|
|
458
779
|
pollTimeout: stringFlag(flags, "--poll-timeout"),
|
|
459
780
|
once: boolFlag(flags, "--once"),
|
|
460
781
|
json,
|
|
782
|
+
// APRV-390.
|
|
783
|
+
allowCrossInstance: boolFlag(flags, "--allow-cross-instance"),
|
|
461
784
|
log: (message) => streams.err(`${message}\n`),
|
|
462
785
|
// APRV-144, on by default, `--no-gloss` to turn it off (APRV-197).
|
|
463
786
|
//
|
|
@@ -768,7 +1091,14 @@ function terminalDeliveries(setup, state, now) {
|
|
|
768
1091
|
// `granted`, a declined one `rejected`, a superseded one `withdrawn`
|
|
769
1092
|
// (a newer proposal is the live question now), and a lapsed one `expired`.
|
|
770
1093
|
if (isAttestationActionKey(actionKey)) {
|
|
771
|
-
|
|
1094
|
+
// The LATEST proposal under this key (APRV-455 refuter M2). Every
|
|
1095
|
+
// proposal of one policy hash shares `policy.attest:<sha256>`, and since
|
|
1096
|
+
// a relay may propose bytes already in force a second proposal of an
|
|
1097
|
+
// attested hash is ordinary: settling the live one by the first one's
|
|
1098
|
+
// answer would retire an open prompt as "granted" by somebody else.
|
|
1099
|
+
const proposal = proposalRecords(read.records)
|
|
1100
|
+
.filter((entry) => entry.action_key === actionKey)
|
|
1101
|
+
.at(-1);
|
|
772
1102
|
const derived = proposal === undefined ? null : proposalState(read.records, proposal.seq, now);
|
|
773
1103
|
if (derived === null || derived.state === "open")
|
|
774
1104
|
continue;
|
|
@@ -865,11 +1195,17 @@ export async function dispatchPending(setup, streams, state, now) {
|
|
|
865
1195
|
digests: [],
|
|
866
1196
|
pruned: [],
|
|
867
1197
|
};
|
|
868
|
-
const
|
|
869
|
-
if (!
|
|
870
|
-
result.queueError = { code:
|
|
1198
|
+
const built = buildPendingQueue(setup.logPath, setup.tagOptions, now);
|
|
1199
|
+
if (!built.ok) {
|
|
1200
|
+
result.queueError = { code: built.code, message: built.message };
|
|
871
1201
|
return result;
|
|
872
1202
|
}
|
|
1203
|
+
// APRV-425. One ordering, applied once, so every pass below reads the same
|
|
1204
|
+
// sequence: the annotation sweep, the paced walkthrough's seeding, the
|
|
1205
|
+
// collapse, and the digest grouping. `orderPending` drops nothing and decides
|
|
1206
|
+
// nothing, so the SET this cycle works from is `buildPendingQueue`'s exactly;
|
|
1207
|
+
// what changes is which request an approver meets first.
|
|
1208
|
+
const queue = { ...built, requests: orderPending(built.requests, now) };
|
|
873
1209
|
// APRV-106 (withdrawal) generalized by APRV-113 (every terminal state),
|
|
874
1210
|
// before the sends. A request this process delivered and that the log now
|
|
875
1211
|
// says is settled — granted or rejected at any surface, revoked, expired by
|
|
@@ -945,6 +1281,27 @@ export async function dispatchPending(setup, streams, state, now) {
|
|
|
945
1281
|
forget(state, actionKey);
|
|
946
1282
|
result.pruned.push({ action_key: actionKey, reason: "stale" });
|
|
947
1283
|
}
|
|
1284
|
+
// APRV-442. A tap the gate refused (a wrong account's, `sender-unmapped`, is
|
|
1285
|
+
// the case that found this) disarms its card, and the channel says which
|
|
1286
|
+
// cards it disarmed that way. The card is gone; the REQUEST is not, unless
|
|
1287
|
+
// the verified log says so. A released key the log still calls pending loses
|
|
1288
|
+
// its delivery bookkeeping here, so the sends below offer it again as a
|
|
1289
|
+
// fresh card in this same cycle. One the log has settled is left exactly
|
|
1290
|
+
// where it is, for the terminal annotation pass above to finish on the next
|
|
1291
|
+
// cycle as it always has. Nothing about the refusal changes: it was recorded
|
|
1292
|
+
// by the gate before this ran, and the new card's buttons reach the same gate.
|
|
1293
|
+
for (const actionKey of setup.channel.takeReleased()) {
|
|
1294
|
+
if (!pendingNow.has(actionKey) || !state.delivered.has(actionKey))
|
|
1295
|
+
continue;
|
|
1296
|
+
forget(state, actionKey);
|
|
1297
|
+
// Under `paced` the released card WAS the question in front of the
|
|
1298
|
+
// approver, and a selection that still believed it was showing would
|
|
1299
|
+
// decline to show anything. Clearing it lets the walkthrough re-offer the
|
|
1300
|
+
// request, which is the first pending key in its order not on the phone.
|
|
1301
|
+
if (state.paced.current?.includes(actionKey) === true)
|
|
1302
|
+
state.paced.current = null;
|
|
1303
|
+
result.pruned.push({ action_key: actionKey, reason: "released" });
|
|
1304
|
+
}
|
|
948
1305
|
for (const skipped of queue.skipped) {
|
|
949
1306
|
const token = `${skipped.action_key}:${skipped.code}`;
|
|
950
1307
|
if (state.warned.has(token))
|
|
@@ -1270,6 +1627,119 @@ export const COLLAPSE_STALE_AFTER_MS = abandonedAfterMs(HOOK_DEFAULT_WAIT_MS, HO
|
|
|
1270
1627
|
* save nobody a message. A flood starts at two.
|
|
1271
1628
|
*/
|
|
1272
1629
|
const COLLAPSE_MIN = 2;
|
|
1630
|
+
/**
|
|
1631
|
+
* Pending requests a NEWER pending request has superseded (APRV-425).
|
|
1632
|
+
*
|
|
1633
|
+
* Two requests naming the same payload bytes and the same class are two askings
|
|
1634
|
+
* of one question, and only the newer one has an asker. The shape that produces
|
|
1635
|
+
* them is APRV-287's own: a hook whose wait ran out leaves its request open for
|
|
1636
|
+
* the retry grace, the grace runs out, the hook withdraws it on its NEXT
|
|
1637
|
+
* invocation — and if that next invocation never comes while a later session
|
|
1638
|
+
* asks the same thing, both sit in the queue. Dogfooding produced exactly this
|
|
1639
|
+
* complaint (APRV-118): a live prompt buried behind dead ones, and an approver
|
|
1640
|
+
* rejecting the one they wanted.
|
|
1641
|
+
*
|
|
1642
|
+
* Keyed by `(payload_hash, class)` and not by task, because the task id a
|
|
1643
|
+
* harness adapter mints is fresh per tool call (SPEC.md §10.2), so two askings
|
|
1644
|
+
* of one command never share one. The bytes and the class are what a decision
|
|
1645
|
+
* binds to, which is what makes them the right identity here.
|
|
1646
|
+
*
|
|
1647
|
+
* Computed from the pending set alone, which is re-derived from the verified log
|
|
1648
|
+
* every cycle, so this decides nothing and remembers nothing. A superseded
|
|
1649
|
+
* request is COLLAPSED, never closed: it stays pending in the log, listable, and
|
|
1650
|
+
* decidable from any copy already delivered. Only a human's decision or the
|
|
1651
|
+
* requester's own withdrawal ends it.
|
|
1652
|
+
*/
|
|
1653
|
+
export function supersededPending(requests) {
|
|
1654
|
+
const newest = new Map();
|
|
1655
|
+
const superseded = new Set();
|
|
1656
|
+
for (const request of requests) {
|
|
1657
|
+
// An attestation prompt is never a duplicate of anything: its identity is
|
|
1658
|
+
// the policy bytes it proposes, two proposals of the same bytes are refused
|
|
1659
|
+
// upstream, and one that reached here is the live one.
|
|
1660
|
+
if (request.policy_diff !== undefined)
|
|
1661
|
+
continue;
|
|
1662
|
+
// APRV-445 refutation (S2). A proposal is its own asking, held for days by
|
|
1663
|
+
// design and keyed by the intention it names; another request over the
|
|
1664
|
+
// same bytes is not a newer copy of it, and it is not a newer copy of one.
|
|
1665
|
+
if (isProposalTask(request.task.value))
|
|
1666
|
+
continue;
|
|
1667
|
+
const identity = JSON.stringify([request.payload_hash.value, request.class.value]);
|
|
1668
|
+
const key = request.action_key.value;
|
|
1669
|
+
const at = Date.parse(request.requested_ts.value);
|
|
1670
|
+
const previous = newest.get(identity);
|
|
1671
|
+
if (previous === undefined) {
|
|
1672
|
+
newest.set(identity, { key, at: Number.isNaN(at) ? 0 : at });
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
// Later wins, and an unreadable instant loses: a request whose age cannot be
|
|
1676
|
+
// established is not the one to keep in front of an approver. Ties go to the
|
|
1677
|
+
// one later in log order, which is the one the list already put second.
|
|
1678
|
+
if (Number.isNaN(at) || at < previous.at) {
|
|
1679
|
+
superseded.add(key);
|
|
1680
|
+
continue;
|
|
1681
|
+
}
|
|
1682
|
+
superseded.add(previous.key);
|
|
1683
|
+
newest.set(identity, { key, at });
|
|
1684
|
+
}
|
|
1685
|
+
return superseded;
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* The pending set in the order an approver should meet it (APRV-425).
|
|
1689
|
+
*
|
|
1690
|
+
* Three rules, in this order, and each is a complaint from running the gate:
|
|
1691
|
+
*
|
|
1692
|
+
* 1. **Attestation prompts last.** `buildPendingQueue` puts them there on
|
|
1693
|
+
* purpose: a policy amendment changes the rules every entry above it was
|
|
1694
|
+
* routed by, so an approver reading top to bottom answers the questions asked
|
|
1695
|
+
* under the current policy before changing what the current policy is. That
|
|
1696
|
+
* placement is not this function's to relitigate, so proposals are lifted
|
|
1697
|
+
* out, ordered among themselves by nothing at all (log order), and put back
|
|
1698
|
+
* at the end.
|
|
1699
|
+
* 2. **Live requests first, newest first.** Live means younger than
|
|
1700
|
+
* {@link COLLAPSE_STALE_AFTER_MS}, the hook's wait plus its retry grace,
|
|
1701
|
+
* which is the same boundary the collapse uses — one number, one meaning of
|
|
1702
|
+
* "somebody may still be holding this". Newest first because the newest is
|
|
1703
|
+
* the one most likely to have a tool call blocked on it right now, and the
|
|
1704
|
+
* observed failure was a live prompt buried behind dead ones.
|
|
1705
|
+
* 3. **Stale requests after them, oldest first.** They are what the collapse
|
|
1706
|
+
* takes on a first cycle, and where the collapse does not apply (fewer than
|
|
1707
|
+
* {@link COLLAPSE_MIN} of them) oldest-first is the order to work through a
|
|
1708
|
+
* backlog in.
|
|
1709
|
+
*
|
|
1710
|
+
* Stable inside each bucket, so log order breaks every tie and the result is
|
|
1711
|
+
* deterministic for one log and one instant. It is an ORDER and nothing else:
|
|
1712
|
+
* no request is dropped, none is decided, and the set that comes out is the set
|
|
1713
|
+
* that went in.
|
|
1714
|
+
*/
|
|
1715
|
+
export function orderPending(requests, now) {
|
|
1716
|
+
const nowMs = Date.parse(now);
|
|
1717
|
+
if (Number.isNaN(nowMs))
|
|
1718
|
+
return requests;
|
|
1719
|
+
const proposals = [];
|
|
1720
|
+
const live = [];
|
|
1721
|
+
const stale = [];
|
|
1722
|
+
requests.forEach((request, index) => {
|
|
1723
|
+
if (request.policy_diff !== undefined) {
|
|
1724
|
+
proposals.push(request);
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const parsed = Date.parse(request.requested_ts.value);
|
|
1728
|
+
// An unreadable instant is treated as ancient, which sorts it with the
|
|
1729
|
+
// requests nobody is waiting on. That is the strict side: a request whose
|
|
1730
|
+
// age cannot be established must not displace one whose age is known.
|
|
1731
|
+
const at = Number.isNaN(parsed) ? 0 : parsed;
|
|
1732
|
+
// APRV-445 refutation (S2): a proposal waits on a human for as long as its
|
|
1733
|
+
// TTL says (days), and its requester polls rather than blocks, so the
|
|
1734
|
+
// hook's wait-plus-grace boundary says nothing about whether anyone is
|
|
1735
|
+
// still holding it. It is always live.
|
|
1736
|
+
const stalePosition = nowMs - at >= COLLAPSE_STALE_AFTER_MS && !isProposalTask(request.task.value);
|
|
1737
|
+
(stalePosition ? stale : live).push({ request, at, index });
|
|
1738
|
+
});
|
|
1739
|
+
live.sort((a, b) => b.at - a.at || a.index - b.index);
|
|
1740
|
+
stale.sort((a, b) => a.at - b.at || a.index - b.index);
|
|
1741
|
+
return [...live.map((entry) => entry.request), ...stale.map((entry) => entry.request), ...proposals];
|
|
1742
|
+
}
|
|
1273
1743
|
/**
|
|
1274
1744
|
* A DURATION in words, which is not what {@link ageText} renders.
|
|
1275
1745
|
*
|
|
@@ -1284,8 +1754,17 @@ function durationText(ms) {
|
|
|
1284
1754
|
const minutes = Math.round(ms / 60_000);
|
|
1285
1755
|
return `${String(minutes)}m`;
|
|
1286
1756
|
}
|
|
1287
|
-
/**
|
|
1288
|
-
|
|
1757
|
+
/**
|
|
1758
|
+
* The computed lines a collapsed re-delivery leads with (APRV-287).
|
|
1759
|
+
*
|
|
1760
|
+
* `superseded` is the subset a newer pending request replaced (APRV-425), and
|
|
1761
|
+
* the first line states the split rather than claiming every member is old.
|
|
1762
|
+
* Before this the line said "all older than the hook's wait plus its retry
|
|
1763
|
+
* grace", which stopped being true the moment the collapse widened: a message
|
|
1764
|
+
* that overstates what it collapsed is the stale-documentation failure in
|
|
1765
|
+
* message form, and this one is read by the person deciding.
|
|
1766
|
+
*/
|
|
1767
|
+
export function staleLines(requests, now, superseded = new Set()) {
|
|
1289
1768
|
const nowMs = Date.parse(now);
|
|
1290
1769
|
const ages = requests
|
|
1291
1770
|
.map((request) => nowMs - Date.parse(request.requested_ts.value))
|
|
@@ -1296,18 +1775,25 @@ export function staleLines(requests, now) {
|
|
|
1296
1775
|
const cls = request.class.value;
|
|
1297
1776
|
tally.set(cls, (tally.get(cls) ?? 0) + 1);
|
|
1298
1777
|
}
|
|
1778
|
+
const replaced = requests.filter((request) => superseded.has(request.action_key.value)).length;
|
|
1779
|
+
const abandoned = requests.length - replaced;
|
|
1780
|
+
const why = replaced === 0
|
|
1781
|
+
? `all older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace`
|
|
1782
|
+
: abandoned === 0
|
|
1783
|
+
? "all superseded by a newer pending request for the same bytes and class"
|
|
1784
|
+
: `${String(abandoned)} older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace, ${String(replaced)} superseded by a newer pending request for the same bytes and class`;
|
|
1299
1785
|
return [
|
|
1300
|
-
`${String(requests.length)} pending requests,
|
|
1786
|
+
`${String(requests.length)} pending requests, ${why}`,
|
|
1301
1787
|
`oldest: ${oldest === null ? "unknown age" : ageText(oldest)}`,
|
|
1302
1788
|
`classes: ${[...tally.entries()]
|
|
1303
1789
|
.map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
|
|
1304
1790
|
.join(", ")}`,
|
|
1305
|
-
"collapsed into this one message because
|
|
1791
|
+
"collapsed into this one message because nothing is waiting on them; each one stays pending in the log, and a decision on any of them can still authorize an identical retry",
|
|
1306
1792
|
];
|
|
1307
1793
|
}
|
|
1308
1794
|
/**
|
|
1309
1795
|
* Put the requests nobody is waiting on into ONE message, and hand back the
|
|
1310
|
-
* ones that still get a message each (APRV-287).
|
|
1796
|
+
* ones that still get a message each (APRV-287, widened by APRV-425).
|
|
1311
1797
|
*
|
|
1312
1798
|
* Called on a process's first cycle only, which is exactly a daemon start or a
|
|
1313
1799
|
* listener reconnect. Everything it does is bookkeeping in the sense SPEC.md
|
|
@@ -1315,7 +1801,50 @@ export function staleLines(requests, now) {
|
|
|
1315
1801
|
* so a summary that fails to send, or a process that forgets it sent one,
|
|
1316
1802
|
* degrades to showing those requests again, and never to a pending request
|
|
1317
1803
|
* nobody is shown.
|
|
1804
|
+
*
|
|
1805
|
+
* ## Two ways in, one exit (APRV-425)
|
|
1806
|
+
*
|
|
1807
|
+
* A request is collapsed when it is older than the hook's wait plus its retry
|
|
1808
|
+
* grace (nobody is holding it), or when a NEWER pending request names the same
|
|
1809
|
+
* payload bytes and class ({@link supersededPending} — the newer one is the live
|
|
1810
|
+
* asking, whatever the age of either). The second is what answers the observed
|
|
1811
|
+
* complaint that a live prompt arrives buried behind dead ones.
|
|
1812
|
+
*
|
|
1813
|
+
* ## This is where the sent-record is rebuilt from the log (AC3)
|
|
1814
|
+
*
|
|
1815
|
+
* Every collapsed member is written into `state.delivered` and `state.sentAtMs`
|
|
1816
|
+
* below, and the set it is written from is the pending set re-derived from the
|
|
1817
|
+
* verified log. That is the whole of the rebuilding, and it is deliberately not
|
|
1818
|
+
* a second mechanism: the log is the only place the question "what is pending"
|
|
1819
|
+
* is answered.
|
|
1820
|
+
*
|
|
1821
|
+
* What it is NOT is evidence of delivery, and the difference is load-bearing.
|
|
1822
|
+
* Nothing reads this record to conclude that an approver saw anything: a
|
|
1823
|
+
* collapsed request stays pending in the log, is listed by `/queue`, and is
|
|
1824
|
+
* decidable from any copy already delivered, because APRV-196 made
|
|
1825
|
+
* `callback_data` carry a restart-stable action ref so a button on a
|
|
1826
|
+
* pre-restart copy still decides the request. Lose the record and the next
|
|
1827
|
+
* cycle shows the requests again, which is the degradation SPEC.md §10.3
|
|
1828
|
+
* requires and the one this code takes.
|
|
1829
|
+
*/
|
|
1830
|
+
/**
|
|
1831
|
+
* The members a first-cycle collapse would put into one message: stale or
|
|
1832
|
+
* superseded, and never a proposal (APRV-445 refutation, S2). Proposals are
|
|
1833
|
+
* long-lived by design, so after a six-minute outage every one of them would
|
|
1834
|
+
* otherwise land in one digest whose only bulk answer is reject-all.
|
|
1318
1835
|
*/
|
|
1836
|
+
export function collapsibleStale(undecided, now, superseded = supersededPending([...undecided])) {
|
|
1837
|
+
const nowMs = Date.parse(now);
|
|
1838
|
+
if (Number.isNaN(nowMs))
|
|
1839
|
+
return [];
|
|
1840
|
+
return undecided.filter((request) => {
|
|
1841
|
+
if (isProposalTask(request.task.value))
|
|
1842
|
+
return false;
|
|
1843
|
+
const at = Date.parse(request.requested_ts.value);
|
|
1844
|
+
const age = Number.isNaN(at) ? 0 : nowMs - at;
|
|
1845
|
+
return age >= COLLAPSE_STALE_AFTER_MS || superseded.has(request.action_key.value);
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1319
1848
|
async function collapseStale(setup, streams, state, result, undecided, now) {
|
|
1320
1849
|
const nowMs = Date.parse(now);
|
|
1321
1850
|
if (Number.isNaN(nowMs))
|
|
@@ -1324,12 +1853,17 @@ async function collapseStale(setup, streams, state, result, undecided, now) {
|
|
|
1324
1853
|
const at = Date.parse(request.requested_ts.value);
|
|
1325
1854
|
return Number.isNaN(at) ? 0 : nowMs - at;
|
|
1326
1855
|
};
|
|
1327
|
-
|
|
1856
|
+
// Computed over the WHOLE pending set rather than over `undecided`, so a
|
|
1857
|
+
// request this process has already delivered still supersedes an older twin.
|
|
1858
|
+
const superseded = supersededPending(undecided);
|
|
1859
|
+
const stale = collapsibleStale(undecided, now, superseded);
|
|
1328
1860
|
if (stale.length < COLLAPSE_MIN)
|
|
1329
1861
|
return undecided;
|
|
1330
1862
|
let delivered = null;
|
|
1331
1863
|
try {
|
|
1332
|
-
delivered = await setup.channel.notifyStale(stale, {
|
|
1864
|
+
delivered = await setup.channel.notifyStale(stale, {
|
|
1865
|
+
lines: staleLines(stale, now, superseded),
|
|
1866
|
+
});
|
|
1333
1867
|
}
|
|
1334
1868
|
catch (cause) {
|
|
1335
1869
|
delivered = null;
|
|
@@ -1538,7 +2072,7 @@ function report(setup, streams, digestId, members) {
|
|
|
1538
2072
|
* Report a steady-state cycle's problems on stderr. Startup reports its own,
|
|
1539
2073
|
* as exit codes, in {@link runListener}.
|
|
1540
2074
|
*/
|
|
1541
|
-
function reportCycle(result, streams) {
|
|
2075
|
+
export function reportCycle(result, streams) {
|
|
1542
2076
|
if (result.queueError !== undefined) {
|
|
1543
2077
|
streams.err(`approval: telegram cannot read the pending queue (${result.queueError.code}): ${result.queueError.message} — retrying next cycle\n`);
|
|
1544
2078
|
}
|
|
@@ -1569,7 +2103,10 @@ function handlerFor(setup, streams) {
|
|
|
1569
2103
|
})}\n`);
|
|
1570
2104
|
}
|
|
1571
2105
|
else if (result.outcome.ok) {
|
|
1572
|
-
|
|
2106
|
+
// APRV-324: the actor off the RECORD, not the one this process was
|
|
2107
|
+
// launched with. Under a sender mapping they differ, and the line a human
|
|
2108
|
+
// reads on the operator's terminal has to say who the log says decided.
|
|
2109
|
+
streams.out(`${decision.decision === "grant" ? "granted" : "rejected"} ${decision.action_key} (seq ${result.outcome.record.seq}) by ${result.outcome.record.actor} via telegram\n`);
|
|
1573
2110
|
}
|
|
1574
2111
|
else {
|
|
1575
2112
|
streams.err(`approval: telegram decision refused (${result.outcome.code}): ${result.outcome.message}\n`);
|
|
@@ -1606,8 +2143,101 @@ function handlerFor(setup, streams) {
|
|
|
1606
2143
|
* request here to reject, and a checkpoint that is owed is a warning at every
|
|
1607
2144
|
* layer and a refusal at none.
|
|
1608
2145
|
*/
|
|
2146
|
+
/**
|
|
2147
|
+
* Where this listener's policy lives, as `loadPolicy` wants it (APRV-324
|
|
2148
|
+
* follow-up).
|
|
2149
|
+
*
|
|
2150
|
+
* The same location every gate operation this process performs resolves,
|
|
2151
|
+
* because the handlers below ask the policy who a sender is and the gate asks
|
|
2152
|
+
* it what a class resolves to, and two different files answering those two
|
|
2153
|
+
* questions would be a listener enforcing one policy and attributing under
|
|
2154
|
+
* another.
|
|
2155
|
+
*/
|
|
2156
|
+
function policyLoadOptions(setup) {
|
|
2157
|
+
const policy = setup.gateOptions.policy;
|
|
2158
|
+
if (policy?.file !== undefined)
|
|
2159
|
+
return { file: policy.file };
|
|
2160
|
+
if (policy?.dir !== undefined)
|
|
2161
|
+
return { dir: policy.dir };
|
|
2162
|
+
return {};
|
|
2163
|
+
}
|
|
2164
|
+
function senderIdentityFor(setup, sender) {
|
|
2165
|
+
const load = loadPolicy(policyLoadOptions(setup));
|
|
2166
|
+
if (sender === undefined)
|
|
2167
|
+
return { ok: true, actor: setup.actor };
|
|
2168
|
+
// APRV-370. The operator's sender key, and with it the form the two refusals
|
|
2169
|
+
// below record the account in. Those two never reach the in-force mapping,
|
|
2170
|
+
// so they take `recordedSenderFor`'s rule: the form follows the FILE, the
|
|
2171
|
+
// mapping follows the ATTESTATION.
|
|
2172
|
+
const key = senderKeyFrom();
|
|
2173
|
+
if (load.ok) {
|
|
2174
|
+
const read = readVerifiedRecords(setup.logPath);
|
|
2175
|
+
if (!read.ok) {
|
|
2176
|
+
return {
|
|
2177
|
+
ok: false,
|
|
2178
|
+
code: "policy-not-attested",
|
|
2179
|
+
sender: recordedSenderFor(load, sender, key),
|
|
2180
|
+
message: `the log could not be read to check whether ${load.source.filename} is attested (${read.code}): ${read.message}. Nothing was recorded; a decision made at a terminal carries no sender and is unaffected.`,
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
// `core/attest.ts`'s own check and its own wording, rather than a second
|
|
2184
|
+
// comparison written here that could come to a different conclusion about
|
|
2185
|
+
// the same file than the one the gate's refusal is built on.
|
|
2186
|
+
const refusal = attestationRefusal(checkAttestation(read.records, load.source.path));
|
|
2187
|
+
if (refusal !== null) {
|
|
2188
|
+
return {
|
|
2189
|
+
ok: false,
|
|
2190
|
+
code: refusal.code,
|
|
2191
|
+
sender: recordedSenderFor(load, sender, key),
|
|
2192
|
+
message: `${refusal.message}. The sender mapping it declares is therefore not in force, so the account this gesture came from cannot be resolved against it and nothing was recorded. Re-attest the policy, or do this from a terminal, which authenticates no sender and is unaffected by the mapping.`,
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
return actorForSender(load, setup.actor, sender, key);
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* Record that a gesture this listener refused was made (APRV-355).
|
|
2200
|
+
*
|
|
2201
|
+
* Best-effort, and after the refusal is already decided: the card the person
|
|
2202
|
+
* sees does not depend on this write landing. A failure is reported on stderr
|
|
2203
|
+
* beside the refusal it was about, because a record that silently did not land
|
|
2204
|
+
* is exactly the gap this task exists to close, and one that fails quietly is
|
|
2205
|
+
* the same gap wearing a fix.
|
|
2206
|
+
*
|
|
2207
|
+
* The runtime cannot name a person on `sender-unmapped` or `sender-ambiguous`,
|
|
2208
|
+
* which is the whole point of those refusals, so `actor` is the listener's
|
|
2209
|
+
* configured identity ONLY where the resolution produced one. The observed
|
|
2210
|
+
* account goes in either way; it is the only thing known about who tapped.
|
|
2211
|
+
*/
|
|
2212
|
+
function recordGestureRefusal(setup, streams, gesture, refused) {
|
|
2213
|
+
const recorded = recordRefusedGesture(setup.logPath, {
|
|
2214
|
+
gesture,
|
|
2215
|
+
actor: null,
|
|
2216
|
+
channel: "telegram",
|
|
2217
|
+
sender: refused.sender,
|
|
2218
|
+
}, { code: refused.code, message: refused.message }, setup.gateOptions);
|
|
2219
|
+
if (!recorded.ok) {
|
|
2220
|
+
streams.err(`approval: telegram ${gesture} refusal could not be recorded (${recorded.code}): ${recorded.message}\n`);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
1609
2223
|
export function checkpointHandlerFor(setup, streams) {
|
|
1610
2224
|
return (tap) => {
|
|
2225
|
+
// APRV-324 follow-up, and FIRST, before the decline branch is even
|
|
2226
|
+
// considered: a signature says this log's head is what this person saw, so
|
|
2227
|
+
// an account the ATTESTED policy does not name may not produce one. Under
|
|
2228
|
+
// no mapping this resolves to the configured identity and the whole branch
|
|
2229
|
+
// is today's behaviour.
|
|
2230
|
+
const resolved = senderIdentityFor(setup, tap.sender);
|
|
2231
|
+
if (!resolved.ok) {
|
|
2232
|
+
streams.err(`approval: telegram checkpoint refused (${resolved.code}): ${resolved.message}\n`);
|
|
2233
|
+
recordGestureRefusal(setup, streams, "checkpoint-signature", resolved);
|
|
2234
|
+
return {
|
|
2235
|
+
ok: false,
|
|
2236
|
+
headline: TELEGRAM_NOT_RECORDED,
|
|
2237
|
+
detail: [refusedDecisionLine(resolved.code)],
|
|
2238
|
+
toast: "Not signed.",
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
1611
2241
|
if (!tap.sign) {
|
|
1612
2242
|
return {
|
|
1613
2243
|
ok: true,
|
|
@@ -1619,7 +2249,7 @@ export function checkpointHandlerFor(setup, streams) {
|
|
|
1619
2249
|
toast: "Not now.",
|
|
1620
2250
|
};
|
|
1621
2251
|
}
|
|
1622
|
-
const result = signCheckpointOffer(setup.checkpoint, tap.head,
|
|
2252
|
+
const result = signCheckpointOffer(setup.checkpoint, tap.head, resolved.actor, "telegram", process.cwd());
|
|
1623
2253
|
const lines = checkpointSignedLines(result);
|
|
1624
2254
|
const [headline, ...detail] = lines;
|
|
1625
2255
|
if (setup.json) {
|
|
@@ -1632,7 +2262,7 @@ export function checkpointHandlerFor(setup, streams) {
|
|
|
1632
2262
|
})}\n`);
|
|
1633
2263
|
}
|
|
1634
2264
|
else if (result.ok) {
|
|
1635
|
-
streams.out(`checkpoint ${String(result.seq)}: signed head seq ${String(result.signed.seq)} ${result.signed.hash} by ${
|
|
2265
|
+
streams.out(`checkpoint ${String(result.seq)}: signed head seq ${String(result.signed.seq)} ${result.signed.hash} by ${resolved.actor} via telegram\n`);
|
|
1636
2266
|
}
|
|
1637
2267
|
else {
|
|
1638
2268
|
streams.err(`approval: telegram checkpoint refused (${result.code}): ${result.message}\n`);
|
|
@@ -1668,10 +2298,36 @@ export function checkpointHandlerFor(setup, streams) {
|
|
|
1668
2298
|
*/
|
|
1669
2299
|
export function reviewHandlerFor(setup, streams) {
|
|
1670
2300
|
return (tap) => {
|
|
1671
|
-
|
|
2301
|
+
// APRV-324 follow-up. A review confers no authority and is still a HUMAN's
|
|
2302
|
+
// observation: `approval feedback` hands it to agents as human-authored
|
|
2303
|
+
// guidance, so a review attributed to the wrong person is guidance in
|
|
2304
|
+
// somebody else's name. Resolved against the ATTESTED policy exactly as a
|
|
2305
|
+
// signature is, and under no mapping this is the configured identity and
|
|
2306
|
+
// today's behaviour.
|
|
2307
|
+
const resolved = senderIdentityFor(setup, tap.sender);
|
|
2308
|
+
if (!resolved.ok) {
|
|
2309
|
+
streams.err(`approval: telegram review refused (${resolved.code}): ${resolved.message}\n`);
|
|
2310
|
+
// `review-note` when the tap carried words: a note is attention spent
|
|
2311
|
+
// writing rather than attention spent tapping, and an operator reading
|
|
2312
|
+
// this record wants to know which they lost (APRV-355).
|
|
2313
|
+
recordGestureRefusal(setup, streams, tap.note === undefined || tap.note.trim().length === 0 ? "review" : "review-note", resolved);
|
|
2314
|
+
return {
|
|
2315
|
+
ok: false,
|
|
2316
|
+
headline: TELEGRAM_NOT_RECORDED,
|
|
2317
|
+
detail: [refusedDecisionLine(resolved.code)],
|
|
2318
|
+
toast: "Not recorded.",
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
const result = reviewSample(setup.logPath, { kind: "seq", seq: tap.sampleSeq }, resolved.actor, tap.note ?? null, {
|
|
1672
2322
|
...(setup.gateOptions.policy === undefined ? {} : { policy: setup.gateOptions.policy }),
|
|
1673
2323
|
verdict: tap.verdict,
|
|
1674
2324
|
...(tap.reaction === undefined ? {} : { reaction: tap.reaction }),
|
|
2325
|
+
...(resolved.sender === undefined
|
|
2326
|
+
? {}
|
|
2327
|
+
: {
|
|
2328
|
+
sender: resolved.sender,
|
|
2329
|
+
...(resolved.source === undefined ? {} : { senderSource: resolved.source }),
|
|
2330
|
+
}),
|
|
1675
2331
|
});
|
|
1676
2332
|
if (!result.ok) {
|
|
1677
2333
|
// SPEC.md §11.1 invariant 6: the code is machine-readable and distinct,
|
|
@@ -1803,7 +2459,9 @@ clock = () => new Date().toISOString()) {
|
|
|
1803
2459
|
// and `/queue` is the verb that says what that is. Appended rather than
|
|
1804
2460
|
// interleaved, because the two lists answer different questions: one is
|
|
1805
2461
|
// what is waiting on the approver, the other what already happened.
|
|
1806
|
-
|
|
2462
|
+
// APRV-425: the SAME ordering the dispatch cycle uses, because `/queue` is
|
|
2463
|
+
// the approver asking what is waiting and two orders would be two answers.
|
|
2464
|
+
const lines = queueLines(orderPending(queue.requests, now), now, state.paced.current ?? []);
|
|
1807
2465
|
const built = openReviewCards(setup.logPath);
|
|
1808
2466
|
if (built.ok && built.cards.length > 0)
|
|
1809
2467
|
lines.push(...reviewSummaryLines(built.cards, now));
|
|
@@ -1868,7 +2526,24 @@ clock = () => new Date().toISOString()) {
|
|
|
1868
2526
|
* queue from the verified log and re-sends everything still pending, exactly as
|
|
1869
2527
|
* a restarted process would. A duplicate on the phone, never a silence.
|
|
1870
2528
|
*/
|
|
1871
|
-
|
|
2529
|
+
/**
|
|
2530
|
+
* Give the channel every handler the runtime answers a gesture with, and hand
|
|
2531
|
+
* back the fresh dispatch bookkeeping (APRV-424).
|
|
2532
|
+
*
|
|
2533
|
+
* Extracted from {@link startListener} so the webhook runner wires the SAME
|
|
2534
|
+
* four handlers rather than a second set that looks like them. This is the
|
|
2535
|
+
* CLI-side half of the property APRV-424 is about: the transport decides where
|
|
2536
|
+
* an update arrives, and nothing else. A tap that arrives by webhook is
|
|
2537
|
+
* recorded by `handlerFor`'s `recordChannelDecision` — the same actor, the
|
|
2538
|
+
* same sender resolution, the same gate — because it is the same function
|
|
2539
|
+
* object, registered here.
|
|
2540
|
+
*
|
|
2541
|
+
* A FRESH {@link DispatchState} per call, which is the whole of the restart
|
|
2542
|
+
* story: a supervisor that restarts a fallen runner re-derives the pending
|
|
2543
|
+
* queue from the verified log and re-sends everything still pending. A
|
|
2544
|
+
* duplicate on the phone, never a silence.
|
|
2545
|
+
*/
|
|
2546
|
+
export function wireListener(setup, streams) {
|
|
1872
2547
|
const { channel } = setup;
|
|
1873
2548
|
channel.onDecision(handlerFor(setup, streams));
|
|
1874
2549
|
// APRV-257. Registered unconditionally, because whether a checkpoint is ever
|
|
@@ -1899,6 +2574,11 @@ export function startListener(setup, streams) {
|
|
|
1899
2574
|
if (setup.delivery === "paced") {
|
|
1900
2575
|
channel.onCommand(commandHandlerFor(setup, streams, state));
|
|
1901
2576
|
}
|
|
2577
|
+
return state;
|
|
2578
|
+
}
|
|
2579
|
+
export function startListener(setup, streams) {
|
|
2580
|
+
const { channel } = setup;
|
|
2581
|
+
const state = wireListener(setup, streams);
|
|
1902
2582
|
let stopping = false;
|
|
1903
2583
|
const stop = () => {
|
|
1904
2584
|
stopping = true;
|
|
@@ -1927,7 +2607,11 @@ export function startListener(setup, streams) {
|
|
|
1927
2607
|
const beforePoll = async () => {
|
|
1928
2608
|
reportCycle(await dispatchPending(setup, streams, state, new Date().toISOString()), streams);
|
|
1929
2609
|
};
|
|
1930
|
-
|
|
2610
|
+
// APRV-390. The 409's report reads the ownership registry at the moment
|
|
2611
|
+
// the conflict happens; the channel prints what this returns and knows
|
|
2612
|
+
// nothing about instances or files.
|
|
2613
|
+
const conflictAdvice = conflictAdviceFor(setup);
|
|
2614
|
+
await channel.listen(setup.once ? { once: true, beforePoll, conflictAdvice } : { beforePoll, conflictAdvice });
|
|
1931
2615
|
if (setup.json) {
|
|
1932
2616
|
streams.out(`${JSON.stringify({ event: "stopped", ...channel.stats() })}\n`);
|
|
1933
2617
|
}
|
|
@@ -1936,6 +2620,16 @@ export function startListener(setup, streams) {
|
|
|
1936
2620
|
return { done, stop };
|
|
1937
2621
|
}
|
|
1938
2622
|
async function runListener(setup, streams) {
|
|
2623
|
+
// APRV-390. Before anything is delivered and before the first poll: whose
|
|
2624
|
+
// bot is this? An override that got this far still owes the operator the
|
|
2625
|
+
// sentence, because the whole point of `--allow-cross-instance` is that the
|
|
2626
|
+
// deliberate case is deliberate out loud.
|
|
2627
|
+
for (const finding of setup.crossInstance) {
|
|
2628
|
+
streams.err(`approval: --allow-cross-instance: starting anyway — ${finding.detail}\n`);
|
|
2629
|
+
}
|
|
2630
|
+
const claimed = await claimListenerBot(setup, (message) => streams.err(`${message}\n`));
|
|
2631
|
+
if (!claimed.ok)
|
|
2632
|
+
return integrityError(streams, setup.json, claimed.message);
|
|
1939
2633
|
const running = startListener(setup, streams);
|
|
1940
2634
|
const stop = () => running.stop();
|
|
1941
2635
|
process.on("SIGINT", stop);
|
|
@@ -1947,6 +2641,11 @@ async function runListener(setup, streams) {
|
|
|
1947
2641
|
finally {
|
|
1948
2642
|
process.off("SIGINT", stop);
|
|
1949
2643
|
process.off("SIGTERM", stop);
|
|
2644
|
+
// The transport lease goes back on every exit, clean or not (APRV-424
|
|
2645
|
+
// review finding 1). A listener that crashed leaves a lockfile whose pid
|
|
2646
|
+
// is gone and the next taker reclaims it; a listener that stopped politely
|
|
2647
|
+
// leaves nothing to reclaim.
|
|
2648
|
+
claimed.lease.release();
|
|
1950
2649
|
}
|
|
1951
2650
|
switch (outcome.kind) {
|
|
1952
2651
|
case "stopped":
|
|
@@ -1989,6 +2688,10 @@ export function commandTelegramHealth(argv, streams, cwd) {
|
|
|
1989
2688
|
const parsed = parseFlags(argv, {
|
|
1990
2689
|
"--policy": "string",
|
|
1991
2690
|
"--dir": "string",
|
|
2691
|
+
// APRV-390. Which instance's ownership record to read. Accepted here for
|
|
2692
|
+
// the same reason `listen` accepts it: a gate may be configured with its
|
|
2693
|
+
// log somewhere other than beside the policy.
|
|
2694
|
+
"--log": "string",
|
|
1992
2695
|
"--json": "boolean",
|
|
1993
2696
|
"--help": "boolean",
|
|
1994
2697
|
"-h": "boolean",
|
|
@@ -2013,6 +2716,19 @@ export function commandTelegramHealth(argv, streams, cwd) {
|
|
|
2013
2716
|
const token = env(tokenEnv);
|
|
2014
2717
|
const chatId = env(chatEnv);
|
|
2015
2718
|
const ok = token !== null && chatId !== null;
|
|
2719
|
+
// APRV-390. Which bot, and whose. Read from the two ownership records and
|
|
2720
|
+
// never from the network: this verb makes no Bot API call on any path, and
|
|
2721
|
+
// the last `getMe` a listener or a setup run made is already written down.
|
|
2722
|
+
// An instance that has never started a listener has no record, which is a
|
|
2723
|
+
// state and not a fault — the row says so rather than guessing.
|
|
2724
|
+
const logPath = resolvePath(stringFlag(parsed.flags, "--log"), DEFAULT_LOG_PATH, dirFlag === null ? cwd : absolute(dirFlag, cwd));
|
|
2725
|
+
const mine = ownedBot(logPath, "telegram");
|
|
2726
|
+
const others = mine === null ? [] : otherOwnersOf(logPath, mine.botId, mine.apiBase);
|
|
2727
|
+
const ownership = mine === null
|
|
2728
|
+
? "no bot recorded for this instance yet: it is written by the first `approval up` or `approval channel telegram listen` that reaches getMe"
|
|
2729
|
+
: others.length === 0
|
|
2730
|
+
? `bot ${mine.username} (id ${mine.botId}) is owned by this instance, ${mine.instanceHome} (instance ${mine.instanceId}), recorded ${mine.claimedAt}`
|
|
2731
|
+
: `bot ${mine.username} (id ${mine.botId}) is claimed by this instance AND by ${describeOwners(others)}; one of them must be given its own bot`;
|
|
2016
2732
|
if (json) {
|
|
2017
2733
|
streams.out(`${JSON.stringify({
|
|
2018
2734
|
ok,
|
|
@@ -2022,10 +2738,20 @@ export function commandTelegramHealth(argv, streams, cwd) {
|
|
|
2022
2738
|
token_set: token !== null,
|
|
2023
2739
|
chat_env: chatEnv,
|
|
2024
2740
|
chat_id: chatId,
|
|
2741
|
+
// APRV-390. Names and ids, never a value.
|
|
2742
|
+
bot_username: mine?.username ?? null,
|
|
2743
|
+
bot_id: mine?.botId ?? null,
|
|
2744
|
+
owner_instance: mine?.instanceId ?? null,
|
|
2745
|
+
owner_home: mine?.instanceHome ?? null,
|
|
2746
|
+
other_owners: others.map((claim) => ({
|
|
2747
|
+
instance_id: claim.instanceId,
|
|
2748
|
+
instance_home: claim.instanceHome,
|
|
2749
|
+
})),
|
|
2025
2750
|
})}\n`);
|
|
2026
2751
|
}
|
|
2027
2752
|
else if (ok) {
|
|
2028
2753
|
streams.out(`telegram: configured (${tokenEnv} set, chat ${String(chatId)})\n`);
|
|
2754
|
+
streams.out(` ${ownership}\n`);
|
|
2029
2755
|
}
|
|
2030
2756
|
else {
|
|
2031
2757
|
streams.err(`approval: telegram is not configured: ${[
|
|
@@ -2054,6 +2780,15 @@ export function commandTelegram(argv, streams, cwd) {
|
|
|
2054
2780
|
switch (sub) {
|
|
2055
2781
|
case "listen":
|
|
2056
2782
|
return commandTelegramListen(rest, streams, cwd);
|
|
2783
|
+
case "webhook":
|
|
2784
|
+
// APRV-424, and a DYNAMIC import on purpose. The webhook runner reaches
|
|
2785
|
+
// back into this module for `prepareListen`, `wireListener` and
|
|
2786
|
+
// `dispatchPending` — it is the same listener with another arrival — so
|
|
2787
|
+
// a static import here would close the dispatcher/dispatched circle.
|
|
2788
|
+
// An ESM cycle is not a compile error; it is a binding that is
|
|
2789
|
+
// `undefined` in one direction on the day initialisation order changes.
|
|
2790
|
+
// `cli/serve.ts` and `cli/mcp.ts` state the same rule.
|
|
2791
|
+
return import("./channel-telegram-webhook.js").then(({ commandTelegramWebhook }) => commandTelegramWebhook(rest, streams, cwd));
|
|
2057
2792
|
case "health":
|
|
2058
2793
|
return commandTelegramHealth(rest, streams, cwd);
|
|
2059
2794
|
default:
|