switchroom 0.7.15 → 0.10.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 +51 -59
- package/bin/run-hook.sh +27 -11
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +410 -133
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +26937 -5601
- package/dist/host-control/main.js +12702 -0
- package/dist/vault/approvals/kernel-server.js +467 -184
- package/dist/vault/broker/server.js +1430 -724
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +7 -4
- package/profiles/_base/settings.json.hbs +20 -5
- package/profiles/_base/start.sh.hbs +16 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/_shared/telegram-style.md.hbs +20 -90
- package/profiles/_shared/vault-protocol.md.hbs +68 -0
- package/profiles/default/CLAUDE.md +50 -96
- package/profiles/default/CLAUDE.md.hbs +36 -6
- package/profiles/default/workspace/SOUL.md.hbs +12 -5
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +191 -0
- package/skills/switchroom-status/SKILL.md +27 -2
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/token-helpers/SKILL.md +24 -1
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/analytics-posthog.ts +191 -0
- package/telegram-plugin/bridge/bridge.ts +69 -0
- package/telegram-plugin/bridge/ipc-client.ts +4 -1
- package/telegram-plugin/dist/bridge/bridge.js +194 -119
- package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
- package/telegram-plugin/dist/server.js +245 -189
- package/telegram-plugin/first-paint.ts +3 -24
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -40
- package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
- package/telegram-plugin/gateway/boot-probes.ts +166 -123
- package/telegram-plugin/gateway/boot-reason.ts +41 -7
- package/telegram-plugin/gateway/boot-version.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +3499 -1885
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
- package/telegram-plugin/gateway/quarantine.ts +69 -0
- package/telegram-plugin/gateway/quota-cache.ts +9 -4
- package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
- package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
- package/telegram-plugin/gateway/recent-denials.ts +77 -0
- package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
- package/telegram-plugin/history.ts +91 -0
- package/telegram-plugin/hooks/hooks.json +10 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/inbound-classifier.ts +50 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +4 -2
- package/telegram-plugin/permission-rule.ts +51 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/registry/reaper.ts +223 -0
- package/telegram-plugin/retry-api-call.ts +80 -0
- package/telegram-plugin/runtime-metrics.ts +177 -0
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/secret-detect/index.ts +24 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
- package/telegram-plugin/secret-detect/vault-error.ts +78 -11
- package/telegram-plugin/secret-detect/vault-write.ts +14 -2
- package/telegram-plugin/server.js +41795 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +420 -0
- package/telegram-plugin/silent-end.ts +174 -0
- package/telegram-plugin/stream-controller.ts +13 -0
- package/telegram-plugin/stream-reply-handler.ts +7 -0
- package/telegram-plugin/subagent-watcher.ts +213 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
- package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
- package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
- package/telegram-plugin/tests/boot-probes.test.ts +216 -10
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
- package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
- package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
- package/telegram-plugin/tests/history-reaper.test.ts +378 -0
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
- package/telegram-plugin/tests/issues-card.test.ts +49 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
- package/telegram-plugin/tests/permission-rule.test.ts +80 -1
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/races.test.ts +179 -0
- package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
- package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
- package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
- package/telegram-plugin/tests/silence-poke.test.ts +493 -0
- package/telegram-plugin/tests/silent-end.test.ts +206 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
- package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
- package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
- package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
- package/telegram-plugin/turn-signal-tracker.ts +100 -24
- package/telegram-plugin/uat/SETUP.md +210 -35
- package/telegram-plugin/uat/assertions.ts +264 -37
- package/telegram-plugin/uat/driver-info.ts +57 -0
- package/telegram-plugin/uat/driver.ts +590 -51
- package/telegram-plugin/uat/harness.ts +140 -94
- package/telegram-plugin/uat/load-env.test.ts +72 -0
- package/telegram-plugin/uat/load-env.ts +48 -0
- package/telegram-plugin/uat/login.ts +96 -53
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
- package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
- package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
- package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
- package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
- package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
- package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
- package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
- package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
- package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
- package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
- package/telegram-plugin/vault-approval-posture.ts +42 -0
- package/telegram-plugin/welcome-text.ts +1 -0
- package/telegram-plugin/active-pins-sweep.ts +0 -204
- package/telegram-plugin/active-pins.ts +0 -146
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/card-event-log.ts +0 -138
- package/telegram-plugin/dist/foreman/foreman.js +0 -31106
- package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
- package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/pin-event-log.ts +0 -76
- package/telegram-plugin/progress-card-driver.ts +0 -2886
- package/telegram-plugin/progress-card-pin-manager.ts +0 -589
- package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
- package/telegram-plugin/progress-card.ts +0 -1409
- package/telegram-plugin/tests/HARNESS.md +0 -340
- package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
- package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
- package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
- package/telegram-plugin/tests/active-pins.test.ts +0 -187
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/card-event-log.test.ts +0 -145
- package/telegram-plugin/tests/first-paint.test.ts +0 -257
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
- package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
- package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
- package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
- package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
- package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
- package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
- package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
- package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
- package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
- package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
- package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
- package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
- package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
- package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
- package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
- package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
- package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
- package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
- package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
- package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
- package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
- package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
- package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
- package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
- package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
- package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
- package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
- package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
- package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
- package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
- package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
- package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
- package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
- package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
- package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
- package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
- package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
- package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
- package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
- package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
- package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
- package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
- package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
- package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
- package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
- package/telegram-plugin/two-zone-card.ts +0 -269
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin the contract: the boot card silences its Telegram notification
|
|
3
|
+
* (passes `disable_notification: true` to `sendMessage`) iff the
|
|
4
|
+
* restart marker's `reason` text starts with `"operator:"`.
|
|
5
|
+
*
|
|
6
|
+
* Background: every agent in the fleet posts a boot card after a
|
|
7
|
+
* `switchroom update`. Without this gate the operator gets N push
|
|
8
|
+
* notifications for one planned redeploy — once-per-agent on every
|
|
9
|
+
* routine update. User-initiated restarts (`/restart` from chat,
|
|
10
|
+
* `cli: switchroom restart`) and unplanned events (crash, fresh) still
|
|
11
|
+
* notify because the user asked for them or needs to know something
|
|
12
|
+
* went wrong.
|
|
13
|
+
*
|
|
14
|
+
* The toggle is keyed on the reason TEXT (`opts.restartReasonDetail`),
|
|
15
|
+
* not the RestartReason enum, because the enum collapses all
|
|
16
|
+
* marker-bearing restarts into `'graceful'` — losing the operator-vs-
|
|
17
|
+
* user distinction. The reason text is the source of truth for who
|
|
18
|
+
* triggered the restart.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest'
|
|
22
|
+
import { startBootCard } from '../gateway/boot-card.js'
|
|
23
|
+
import type { BotApiForBootCard } from '../gateway/boot-card.js'
|
|
24
|
+
|
|
25
|
+
/** Capture sendMessage opts for assertion. editMessageText is a no-op. */
|
|
26
|
+
function makeCapturingBot(): {
|
|
27
|
+
bot: BotApiForBootCard
|
|
28
|
+
sends: Array<{ chatId: string; text: string; opts: Record<string, unknown> }>
|
|
29
|
+
} {
|
|
30
|
+
const sends: Array<{ chatId: string; text: string; opts: Record<string, unknown> }> = []
|
|
31
|
+
const bot: BotApiForBootCard = {
|
|
32
|
+
sendMessage: async (chatId, text, opts) => {
|
|
33
|
+
sends.push({ chatId, text, opts: opts ?? {} })
|
|
34
|
+
return { message_id: 42 }
|
|
35
|
+
},
|
|
36
|
+
editMessageText: async () => ({}),
|
|
37
|
+
}
|
|
38
|
+
return { bot, sends }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Common opts — only the reason-detail varies per test. */
|
|
42
|
+
function mkOpts(overrides: { restartReasonDetail?: string; restartReason?: 'planned' | 'graceful' | 'crash' | 'fresh' } = {}) {
|
|
43
|
+
return {
|
|
44
|
+
agentName: 'TestAgent',
|
|
45
|
+
agentSlug: 'test-agent',
|
|
46
|
+
version: 'v0.0.0-test',
|
|
47
|
+
agentDir: '/tmp/test-agent',
|
|
48
|
+
gatewayInfo: { pid: 1, startedAtMs: Date.now() },
|
|
49
|
+
restartReason: overrides.restartReason ?? 'graceful' as const,
|
|
50
|
+
restartReasonDetail: overrides.restartReasonDetail,
|
|
51
|
+
// Disable the live loop + probes — we only want the initial sendMessage.
|
|
52
|
+
agentLiveWindowMs: 0,
|
|
53
|
+
settleWindowMs: 1_000_000,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('boot card — silent-on-operator-reason', () => {
|
|
58
|
+
it('passes disable_notification: true when restartReasonDetail starts with "operator:"', async () => {
|
|
59
|
+
const { bot, sends } = makeCapturingBot()
|
|
60
|
+
await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'operator: switchroom update' }))
|
|
61
|
+
expect(sends).toHaveLength(1)
|
|
62
|
+
expect(sends[0]!.opts.disable_notification).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('omits disable_notification when restartReasonDetail starts with "user:"', async () => {
|
|
66
|
+
const { bot, sends } = makeCapturingBot()
|
|
67
|
+
await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'user: /restart from chat' }))
|
|
68
|
+
expect(sends).toHaveLength(1)
|
|
69
|
+
expect(sends[0]!.opts.disable_notification).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('omits disable_notification when restartReasonDetail starts with "cli:"', async () => {
|
|
73
|
+
const { bot, sends } = makeCapturingBot()
|
|
74
|
+
await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'cli: switchroom restart' }))
|
|
75
|
+
expect(sends).toHaveLength(1)
|
|
76
|
+
expect(sends[0]!.opts.disable_notification).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('omits disable_notification when restartReasonDetail is undefined (crash / fresh path)', async () => {
|
|
80
|
+
const { bot, sends } = makeCapturingBot()
|
|
81
|
+
await startBootCard('chat1', undefined, bot, mkOpts({ restartReason: 'crash' }))
|
|
82
|
+
expect(sends).toHaveLength(1)
|
|
83
|
+
expect(sends[0]!.opts.disable_notification).toBeUndefined()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('omits disable_notification when restartReasonDetail is empty string', async () => {
|
|
87
|
+
const { bot, sends } = makeCapturingBot()
|
|
88
|
+
await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: '' }))
|
|
89
|
+
expect(sends).toHaveLength(1)
|
|
90
|
+
expect(sends[0]!.opts.disable_notification).toBeUndefined()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('matches the "operator:" prefix exactly — "operator-ish" should NOT silence', async () => {
|
|
94
|
+
// Defence against future operator-side reasons that don't actually
|
|
95
|
+
// want silent — confirms we're matching the prefix-with-colon shape,
|
|
96
|
+
// not a fuzzy contains.
|
|
97
|
+
const { bot, sends } = makeCapturingBot()
|
|
98
|
+
await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'operator-ish: rolled over' }))
|
|
99
|
+
expect(sends).toHaveLength(1)
|
|
100
|
+
// 'operator-ish:' does NOT start with 'operator:' so still notifies.
|
|
101
|
+
expect(sends[0]!.opts.disable_notification).toBeUndefined()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Covers:
|
|
5
5
|
* - #208: probeAgentProcess — deactivating → 🟡 (not 🔴), re-probe loop
|
|
6
|
-
* - #
|
|
6
|
+
* - #1163: probeQuota — uses /v1/messages headers path (was /api/oauth/usage)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
10
|
-
import { mkdtempSync, rmSync } from 'fs'
|
|
10
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs'
|
|
11
11
|
import { tmpdir } from 'os'
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
|
+
probeAccount,
|
|
15
16
|
probeAgentProcess,
|
|
16
17
|
probeScheduler,
|
|
17
18
|
probeBroker,
|
|
@@ -242,7 +243,7 @@ describe('probeAgentProcess — #208: re-probe loop resolves transient', () => {
|
|
|
242
243
|
})
|
|
243
244
|
})
|
|
244
245
|
|
|
245
|
-
// ── #
|
|
246
|
+
// ── #1163: probeQuota — /v1/messages headers path ─────────────────────────
|
|
246
247
|
|
|
247
248
|
import { writeFileSync, mkdirSync } from 'fs'
|
|
248
249
|
import { writeQuotaCache } from '../gateway/quota-cache.js'
|
|
@@ -272,20 +273,38 @@ afterEach(() => {
|
|
|
272
273
|
rmSync(tmp, { recursive: true, force: true })
|
|
273
274
|
})
|
|
274
275
|
|
|
275
|
-
describe('probeQuota — #
|
|
276
|
-
|
|
276
|
+
describe('probeQuota — #1163: /v1/messages headers path', () => {
|
|
277
|
+
// The `/api/oauth/usage` endpoint has been deprecated/tightened —
|
|
278
|
+
// probeQuota now uses `fetchQuota` against `/v1/messages` and reads
|
|
279
|
+
// the unified-ratelimit response headers, same path /status uses.
|
|
280
|
+
it('reports ok with utilization line on a healthy response', async () => {
|
|
281
|
+
const headers = new Headers({
|
|
282
|
+
'anthropic-ratelimit-unified-5h-utilization': '0.42',
|
|
283
|
+
'anthropic-ratelimit-unified-7d-utilization': '0.18',
|
|
284
|
+
})
|
|
277
285
|
const fakeFetch: typeof fetch = async () =>
|
|
278
|
-
new Response(
|
|
286
|
+
new Response('{}', { status: 200, headers }) as Response
|
|
279
287
|
|
|
280
288
|
const result = await probeQuota(claudeDir, agentDir, fakeFetch)
|
|
281
289
|
expect(result.status).toBe('ok')
|
|
282
290
|
expect(result.label).toBe('Quota')
|
|
283
|
-
expect(result.detail).
|
|
284
|
-
|
|
285
|
-
expect(result.rateLimited).toBe(true)
|
|
291
|
+
expect(result.detail).toContain('42% / 5h')
|
|
292
|
+
expect(result.detail).toContain('18% / 7d')
|
|
286
293
|
})
|
|
287
294
|
|
|
288
|
-
it('
|
|
295
|
+
it('surfaces auth rejection with the RFC-H replace-account hint on 403', async () => {
|
|
296
|
+
const fakeFetch: typeof fetch = async () =>
|
|
297
|
+
new Response(null, { status: 403 }) as Response
|
|
298
|
+
|
|
299
|
+
const result = await probeQuota(claudeDir, agentDir, fakeFetch)
|
|
300
|
+
expect(result.status).toBe('degraded')
|
|
301
|
+
// Post-RFC-H: per-agent `auth login` is retired. probeQuota emits the
|
|
302
|
+
// broker-aware "replace the account" hint pointing at `auth add ...
|
|
303
|
+
// --replace` instead. See telegram-plugin/gateway/boot-probes.ts.
|
|
304
|
+
expect(result.nextStep).toMatch(/switchroom auth add .*--from-oauth --replace/)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('writing rate-limited result to cache produces a readable 30 s entry', () => {
|
|
289
308
|
// Verify the cache contract: writeQuotaCache stores rate-limit results
|
|
290
309
|
// with RATE_LIMIT_TTL_MS keyed off rateLimited:true, not the detail string.
|
|
291
310
|
const rateLimitResult = {
|
|
@@ -855,6 +874,67 @@ describe('probeSkills', () => {
|
|
|
855
874
|
})
|
|
856
875
|
})
|
|
857
876
|
|
|
877
|
+
// ── probeAccount nextStep interpolation (PR #1081 reviewer follow-up) ─────
|
|
878
|
+
|
|
879
|
+
describe('probeAccount — nextStep agent-name interpolation', () => {
|
|
880
|
+
let tmpDir: string
|
|
881
|
+
|
|
882
|
+
function setupAgentDir(claudeJson: Record<string, unknown>, tokenMeta?: { expiresAt: number }): string {
|
|
883
|
+
const agentDir = mkdtempSync(join(tmpdir(), 'probe-account-'))
|
|
884
|
+
const claudeDir = join(agentDir, '.claude')
|
|
885
|
+
mkdirSync(claudeDir, { recursive: true })
|
|
886
|
+
writeFileSync(join(claudeDir, '.claude.json'), JSON.stringify(claudeJson))
|
|
887
|
+
if (tokenMeta) {
|
|
888
|
+
writeFileSync(join(claudeDir, '.oauth-token.meta.json'), JSON.stringify(tokenMeta))
|
|
889
|
+
}
|
|
890
|
+
return agentDir
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
afterEach(() => {
|
|
894
|
+
if (tmpDir) {
|
|
895
|
+
try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
|
|
896
|
+
}
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
it('not-signed-in hint interpolates agentName instead of <agent>', async () => {
|
|
900
|
+
tmpDir = setupAgentDir({})
|
|
901
|
+
const result = await probeAccount(tmpDir, { agentName: 'finn' })
|
|
902
|
+
expect(result.status).toBe('degraded')
|
|
903
|
+
expect(result.detail).toBe('not signed in')
|
|
904
|
+
expect(result.nextStep).toBeDefined()
|
|
905
|
+
expect(result.nextStep).toContain('switchroom auth login finn')
|
|
906
|
+
expect(result.nextStep).not.toContain('<agent>')
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
it('expired-token hint interpolates agentName', async () => {
|
|
910
|
+
tmpDir = setupAgentDir(
|
|
911
|
+
{ oauthAccount: { emailAddress: 'me@example.com', billingType: 'max' } },
|
|
912
|
+
{ expiresAt: Date.now() - 86_400_000 }, // expired yesterday
|
|
913
|
+
)
|
|
914
|
+
const result = await probeAccount(tmpDir, { agentName: 'klanker' })
|
|
915
|
+
expect(result.status).toBe('fail')
|
|
916
|
+
expect(result.nextStep).toContain('switchroom auth login klanker')
|
|
917
|
+
expect(result.nextStep).not.toContain('<agent>')
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
it('expiring-soon hint interpolates agentName', async () => {
|
|
921
|
+
tmpDir = setupAgentDir(
|
|
922
|
+
{ oauthAccount: { emailAddress: 'me@example.com', billingType: 'max' } },
|
|
923
|
+
{ expiresAt: Date.now() + 3 * 86_400_000 }, // 3 days left (< 7)
|
|
924
|
+
)
|
|
925
|
+
const result = await probeAccount(tmpDir, { agentName: 'lawgpt' })
|
|
926
|
+
expect(result.status).toBe('degraded')
|
|
927
|
+
expect(result.nextStep).toContain('switchroom auth login lawgpt')
|
|
928
|
+
expect(result.nextStep).not.toContain('<agent>')
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
it('falls back to <agent> placeholder when no agentName provided (backwards-compat)', async () => {
|
|
932
|
+
tmpDir = setupAgentDir({})
|
|
933
|
+
const result = await probeAccount(tmpDir)
|
|
934
|
+
expect(result.nextStep).toContain('<agent>')
|
|
935
|
+
})
|
|
936
|
+
})
|
|
937
|
+
|
|
858
938
|
// ── /proc parser unit tests (synthetic fs) ────────────────────────────────
|
|
859
939
|
|
|
860
940
|
/** Build a /proc/<pid>/stat string for tests. */
|
|
@@ -1012,4 +1092,130 @@ describe('uptimeMsForStarttime', () => {
|
|
|
1012
1092
|
expect(uptimeMsForStarttime(99999999, fs)).toBeNull()
|
|
1013
1093
|
})
|
|
1014
1094
|
})
|
|
1095
|
+
|
|
1096
|
+
// ── nextStep remediation hints on degraded/fail probe branches ──────────────
|
|
1097
|
+
// Every fail/degraded result must carry an actionable `nextStep` per
|
|
1098
|
+
// reference/principles.md principle 1. These tests pin the hints across
|
|
1099
|
+
// the probes covered by the boot-card-dedup-and-next-steps PR so we don't
|
|
1100
|
+
// silently lose the hint on a future refactor.
|
|
1101
|
+
|
|
1102
|
+
describe('nextStep — agent systemd states', () => {
|
|
1103
|
+
it('attaches a journalctl hint when the unit is failed', async () => {
|
|
1104
|
+
const exec = makeSequence([makeSystemctlOutput('failed')])
|
|
1105
|
+
const r = await probeAgentProcess('klanker', {
|
|
1106
|
+
execFileImpl: exec as unknown as (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string }>,
|
|
1107
|
+
sleepImpl: async () => {},
|
|
1108
|
+
retryIntervalMs: 1,
|
|
1109
|
+
retryMaxMs: 0,
|
|
1110
|
+
})
|
|
1111
|
+
expect(r.status).toBe('fail')
|
|
1112
|
+
expect(r.nextStep).toMatch(/journalctl/)
|
|
1113
|
+
expect(r.nextStep).toMatch(/switchroom-klanker/)
|
|
1114
|
+
})
|
|
1115
|
+
|
|
1116
|
+
it('attaches a transient-state hint when the unit is activating after retry budget', async () => {
|
|
1117
|
+
const exec = makeSequence([makeSystemctlOutput('activating')])
|
|
1118
|
+
const r = await probeAgentProcess('klanker', {
|
|
1119
|
+
execFileImpl: exec as unknown as (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string }>,
|
|
1120
|
+
sleepImpl: async () => {},
|
|
1121
|
+
retryIntervalMs: 1,
|
|
1122
|
+
retryMaxMs: 0,
|
|
1123
|
+
})
|
|
1124
|
+
expect(r.status).toBe('degraded')
|
|
1125
|
+
expect(r.nextStep).toMatch(/transient/)
|
|
1126
|
+
expect(r.nextStep).toMatch(/`activating`/)
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
it('attaches a docker-restart hint via the production dockerProbe when no claude in /proc', async () => {
|
|
1130
|
+
// Inject a synthetic /proc with no claude entries — the production
|
|
1131
|
+
// dockerProbe attaches the nextStep hint itself.
|
|
1132
|
+
const { findAgentProcessInContainer } = await import('../gateway/boot-probes.js')
|
|
1133
|
+
const fs = { readdir: () => [] as string[], readFile: () => '' }
|
|
1134
|
+
const found = findAgentProcessInContainer(fs)
|
|
1135
|
+
expect(found).toBeNull()
|
|
1136
|
+
// Now run the docker probe under an override that mimics the
|
|
1137
|
+
// production "claude not found" path so we exercise the nextStep
|
|
1138
|
+
// attachment without depending on the test host's /proc state.
|
|
1139
|
+
const r = await probeAgentProcess('klanker', {
|
|
1140
|
+
dockerMode: true,
|
|
1141
|
+
dockerProbeImpl: () => ({
|
|
1142
|
+
status: 'fail',
|
|
1143
|
+
label: 'Agent',
|
|
1144
|
+
detail: 'claude process not found',
|
|
1145
|
+
nextStep: 'No claude process in container — check container logs with `docker logs <container>` and restart with `switchroom agent restart <agent>`',
|
|
1146
|
+
}),
|
|
1147
|
+
})
|
|
1148
|
+
expect(r.status).toBe('fail')
|
|
1149
|
+
expect(r.nextStep).toMatch(/docker logs/)
|
|
1150
|
+
expect(r.nextStep).toMatch(/restart/)
|
|
1151
|
+
})
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
describe('nextStep — quota / hindsight / broker / kernel / scheduler', () => {
|
|
1155
|
+
it('quota: no OAuth token → degraded with RFC-H add+use hint', async () => {
|
|
1156
|
+
const dir = mkdtempSync(join(tmpdir(), 'quota-nextstep-'))
|
|
1157
|
+
const oldCachePath = process.env.SWITCHROOM_QUOTA_CACHE_PATH
|
|
1158
|
+
process.env.SWITCHROOM_QUOTA_CACHE_PATH = join(dir, 'cache.json')
|
|
1159
|
+
try {
|
|
1160
|
+
const r = await probeQuota(dir, dir, (async () => new Response('{}')) as unknown as typeof fetch)
|
|
1161
|
+
expect(r.status).toBe('degraded')
|
|
1162
|
+
// Post-RFC-H: the no-token nextStep points at `auth add` (register a
|
|
1163
|
+
// fleet account) + `auth use` (set fleet active), not the retired
|
|
1164
|
+
// per-agent `auth login`. See telegram-plugin/gateway/boot-probes.ts.
|
|
1165
|
+
expect(r.nextStep).toMatch(/switchroom auth add .*--from-oauth/)
|
|
1166
|
+
expect(r.nextStep).toMatch(/switchroom auth use/)
|
|
1167
|
+
} finally {
|
|
1168
|
+
if (oldCachePath) process.env.SWITCHROOM_QUOTA_CACHE_PATH = oldCachePath
|
|
1169
|
+
else delete process.env.SWITCHROOM_QUOTA_CACHE_PATH
|
|
1170
|
+
rmSync(dir, { recursive: true, force: true })
|
|
1171
|
+
}
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
it('broker: socket missing → fail with docker compose hint', async () => {
|
|
1175
|
+
const r = await probeBroker('/nonexistent/sock', { dockerMode: true })
|
|
1176
|
+
expect(r.status).toBe('fail')
|
|
1177
|
+
expect(r.nextStep).toMatch(/docker compose/)
|
|
1178
|
+
expect(r.nextStep).toMatch(/vault-broker/)
|
|
1179
|
+
})
|
|
1180
|
+
|
|
1181
|
+
it('kernel: socket missing → fail with docker compose hint', async () => {
|
|
1182
|
+
const r = await probeKernel('/nonexistent/sock', { dockerMode: true })
|
|
1183
|
+
expect(r.status).toBe('fail')
|
|
1184
|
+
expect(r.nextStep).toMatch(/docker compose/)
|
|
1185
|
+
expect(r.nextStep).toMatch(/approval-kernel/)
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
it('scheduler: no lockfile → fail with restart hint (or settling hint inside fresh-boot window)', async () => {
|
|
1189
|
+
const fs: SchedulerFsImpl = { exists: () => false, readFile: () => '', mtimeMs: () => 0 }
|
|
1190
|
+
const r = await probeScheduler('klanker', {
|
|
1191
|
+
dockerMode: true,
|
|
1192
|
+
fs,
|
|
1193
|
+
lockPath: '/state/agent/scheduler.lock',
|
|
1194
|
+
jsonlPath: '/state/agent/scheduler.jsonl',
|
|
1195
|
+
now: () => Date.now(),
|
|
1196
|
+
containerBootTimeMs: null, // disable softening
|
|
1197
|
+
})
|
|
1198
|
+
expect(r.status).toBe('fail')
|
|
1199
|
+
expect(r.nextStep).toMatch(/restart/)
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
it('scheduler: lockfile holder pid dead → degraded with re-check hint', async () => {
|
|
1203
|
+
const fs: SchedulerFsImpl = {
|
|
1204
|
+
exists: (p) => p === '/state/agent/scheduler.lock',
|
|
1205
|
+
readFile: () => '99999\n',
|
|
1206
|
+
mtimeMs: () => 0,
|
|
1207
|
+
}
|
|
1208
|
+
const r = await probeScheduler('klanker', {
|
|
1209
|
+
dockerMode: true,
|
|
1210
|
+
fs,
|
|
1211
|
+
lockPath: '/state/agent/scheduler.lock',
|
|
1212
|
+
jsonlPath: '/state/agent/scheduler.jsonl',
|
|
1213
|
+
isAlive: () => false,
|
|
1214
|
+
now: () => Date.now(),
|
|
1215
|
+
containerBootTimeMs: null,
|
|
1216
|
+
})
|
|
1217
|
+
expect(r.status).toBe('degraded')
|
|
1218
|
+
expect(r.nextStep).toMatch(/re-check/i)
|
|
1219
|
+
})
|
|
1220
|
+
})
|
|
1015
1221
|
})
|
|
Binary file
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin the three-invariant contract for `finalizeCallback`. Every other
|
|
3
|
+
* inline-keyboard callback handler in the gateway routes through this
|
|
4
|
+
* helper, so a regression here breaks the audit-wide button UX
|
|
5
|
+
* (#1150 + follow-ups).
|
|
6
|
+
*
|
|
7
|
+
* 1. Visible press feedback (answerCallbackQuery with text).
|
|
8
|
+
* 2. Keyboard collapses + status line appended (one atomic edit).
|
|
9
|
+
* 3. synthInbound fires AFTER the message edit lands, errors
|
|
10
|
+
* swallowed but logged.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import { finalizeCallback, type FinalizeCallbackContext } from '../inline-keyboard-callbacks.js'
|
|
15
|
+
|
|
16
|
+
interface Capture {
|
|
17
|
+
acks: Array<{ text?: string; show_alert?: boolean }>
|
|
18
|
+
edits: Array<{ text: string; opts: Record<string, unknown> }>
|
|
19
|
+
ackThrows?: Error
|
|
20
|
+
editThrows?: Error
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mkCtx(cap: Capture): FinalizeCallbackContext {
|
|
24
|
+
return {
|
|
25
|
+
answerCallbackQuery: async (opts) => {
|
|
26
|
+
cap.acks.push(opts ?? {})
|
|
27
|
+
if (cap.ackThrows) throw cap.ackThrows
|
|
28
|
+
return true
|
|
29
|
+
},
|
|
30
|
+
editMessageText: async (text, opts) => {
|
|
31
|
+
cap.edits.push({ text, opts: opts ?? {} })
|
|
32
|
+
if (cap.editThrows) throw cap.editThrows
|
|
33
|
+
return { message_id: 1 }
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('finalizeCallback — three-invariant contract', () => {
|
|
39
|
+
it('invariant 1: acks the callback with the supplied toast text', async () => {
|
|
40
|
+
const cap: Capture = { acks: [], edits: [] }
|
|
41
|
+
await finalizeCallback(mkCtx(cap), {
|
|
42
|
+
ackText: 'Approved',
|
|
43
|
+
newText: 'Original prompt\n\n✓ Approved by @op',
|
|
44
|
+
})
|
|
45
|
+
expect(cap.acks).toHaveLength(1)
|
|
46
|
+
expect(cap.acks[0]?.text).toBe('Approved')
|
|
47
|
+
expect(cap.acks[0]?.show_alert).toBeUndefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('invariant 1: alert=true renders as full modal (show_alert: true)', async () => {
|
|
51
|
+
const cap: Capture = { acks: [], edits: [] }
|
|
52
|
+
await finalizeCallback(mkCtx(cap), {
|
|
53
|
+
ackText: 'Vault grant revoked',
|
|
54
|
+
alert: true,
|
|
55
|
+
newText: '...',
|
|
56
|
+
})
|
|
57
|
+
expect(cap.acks[0]?.show_alert).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('invariant 2: strips reply_markup AND edits the body in one atomic call', async () => {
|
|
61
|
+
const cap: Capture = { acks: [], edits: [] }
|
|
62
|
+
await finalizeCallback(mkCtx(cap), {
|
|
63
|
+
ackText: 'Approved',
|
|
64
|
+
newText: '✓ Approved\n\nGrant minted at 22:38 UTC',
|
|
65
|
+
parseMode: 'HTML',
|
|
66
|
+
})
|
|
67
|
+
expect(cap.edits).toHaveLength(1)
|
|
68
|
+
expect(cap.edits[0]?.text).toBe('✓ Approved\n\nGrant minted at 22:38 UTC')
|
|
69
|
+
expect(cap.edits[0]?.opts.reply_markup).toEqual({ inline_keyboard: [] })
|
|
70
|
+
expect(cap.edits[0]?.opts.parse_mode).toBe('HTML')
|
|
71
|
+
// link_preview_options disabled by default — keeps the edited
|
|
72
|
+
// status line from rendering a stale preview card.
|
|
73
|
+
expect(cap.edits[0]?.opts.link_preview_options).toEqual({ is_disabled: true })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('invariant 2: omits parse_mode when not specified (plain text)', async () => {
|
|
77
|
+
const cap: Capture = { acks: [], edits: [] }
|
|
78
|
+
await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'plain' })
|
|
79
|
+
expect(cap.edits[0]?.opts.parse_mode).toBeUndefined()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('invariant 3: synthInbound fires AFTER editMessageText resolves', async () => {
|
|
83
|
+
const order: string[] = []
|
|
84
|
+
const ctx: FinalizeCallbackContext = {
|
|
85
|
+
answerCallbackQuery: async () => { order.push('ack'); return true },
|
|
86
|
+
editMessageText: async () => {
|
|
87
|
+
order.push('edit-start')
|
|
88
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
89
|
+
order.push('edit-end')
|
|
90
|
+
return { message_id: 1 }
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
await finalizeCallback(ctx, {
|
|
94
|
+
ackText: 'ok',
|
|
95
|
+
newText: '...',
|
|
96
|
+
synthInbound: () => { order.push('synth') },
|
|
97
|
+
})
|
|
98
|
+
// ack is fire-and-forget so its position is "no later than edit-start"
|
|
99
|
+
// but we don't pin its exact position. Pin that synth comes AFTER
|
|
100
|
+
// edit-end — that's the guarantee callers need.
|
|
101
|
+
const editEndIdx = order.indexOf('edit-end')
|
|
102
|
+
const synthIdx = order.indexOf('synth')
|
|
103
|
+
expect(editEndIdx).toBeGreaterThanOrEqual(0)
|
|
104
|
+
expect(synthIdx).toBeGreaterThan(editEndIdx)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('invariant 3: async synthInbound is awaited', async () => {
|
|
108
|
+
let synthResolved = false
|
|
109
|
+
const cap: Capture = { acks: [], edits: [] }
|
|
110
|
+
await finalizeCallback(mkCtx(cap), {
|
|
111
|
+
ackText: 'ok',
|
|
112
|
+
newText: '...',
|
|
113
|
+
synthInbound: async () => {
|
|
114
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
115
|
+
synthResolved = true
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
expect(synthResolved).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('invariant 3: synthInbound errors are caught + logged, never propagated', async () => {
|
|
122
|
+
const logs: string[] = []
|
|
123
|
+
const cap: Capture = { acks: [], edits: [] }
|
|
124
|
+
await expect(
|
|
125
|
+
finalizeCallback(mkCtx(cap), {
|
|
126
|
+
ackText: 'ok',
|
|
127
|
+
newText: '...',
|
|
128
|
+
synthInbound: () => { throw new Error('inject_inbound IPC closed') },
|
|
129
|
+
log: (l) => logs.push(l),
|
|
130
|
+
}),
|
|
131
|
+
).resolves.toBeUndefined()
|
|
132
|
+
expect(logs.some((l) => l.includes('inject_inbound IPC closed'))).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('robustness: editMessageText failure does NOT block synthInbound', async () => {
|
|
136
|
+
// Operator deleted the card between tap and our edit — Telegram
|
|
137
|
+
// returns MESSAGE_TO_EDIT_NOT_FOUND. The model still needs to wake
|
|
138
|
+
// up: a stale/missing card is preferred to a stuck conversation.
|
|
139
|
+
const logs: string[] = []
|
|
140
|
+
let synthFired = false
|
|
141
|
+
const cap: Capture = {
|
|
142
|
+
acks: [],
|
|
143
|
+
edits: [],
|
|
144
|
+
editThrows: new Error('Bad Request: message to edit not found'),
|
|
145
|
+
}
|
|
146
|
+
await finalizeCallback(mkCtx(cap), {
|
|
147
|
+
ackText: 'Approved',
|
|
148
|
+
newText: '...',
|
|
149
|
+
synthInbound: () => { synthFired = true },
|
|
150
|
+
log: (l) => logs.push(l),
|
|
151
|
+
})
|
|
152
|
+
expect(synthFired).toBe(true)
|
|
153
|
+
expect(logs.some((l) => l.includes('message to edit not found'))).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('robustness: answerCallbackQuery failure does NOT block edit or synth', async () => {
|
|
157
|
+
// Telegram rejects the ack (e.g. the callback_query is already
|
|
158
|
+
// older than the 60s timeout). The edit + synth still must proceed.
|
|
159
|
+
const logs: string[] = []
|
|
160
|
+
let synthFired = false
|
|
161
|
+
const cap: Capture = {
|
|
162
|
+
acks: [],
|
|
163
|
+
edits: [],
|
|
164
|
+
ackThrows: new Error('query is too old'),
|
|
165
|
+
}
|
|
166
|
+
await finalizeCallback(mkCtx(cap), {
|
|
167
|
+
ackText: 'Approved',
|
|
168
|
+
newText: 'edited body',
|
|
169
|
+
synthInbound: () => { synthFired = true },
|
|
170
|
+
log: (l) => logs.push(l),
|
|
171
|
+
})
|
|
172
|
+
expect(cap.edits).toHaveLength(1)
|
|
173
|
+
expect(cap.edits[0]?.text).toBe('edited body')
|
|
174
|
+
expect(synthFired).toBe(true)
|
|
175
|
+
// ack fire-and-forget — its catch fires asynchronously; give it a tick
|
|
176
|
+
// so the log assertion is stable across runs.
|
|
177
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
178
|
+
expect(logs.some((l) => l.includes('query is too old'))).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('synthInbound is optional — surfaces with no model in the loop just ack + edit', async () => {
|
|
182
|
+
const cap: Capture = { acks: [], edits: [] }
|
|
183
|
+
await finalizeCallback(mkCtx(cap), {
|
|
184
|
+
ackText: 'Dismissed',
|
|
185
|
+
newText: '✗ Dismissed',
|
|
186
|
+
})
|
|
187
|
+
expect(cap.acks).toHaveLength(1)
|
|
188
|
+
expect(cap.edits).toHaveLength(1)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -88,6 +88,32 @@ describe('validateGatewayMessage', () => {
|
|
|
88
88
|
it('rejects missing requestId', () => {
|
|
89
89
|
expect(validateGatewayMessage({ type: 'permission', behavior: 'allow' })).toBe(false)
|
|
90
90
|
})
|
|
91
|
+
|
|
92
|
+
// #1138: optional `rule` field on Always-allow broadcasts. Must be
|
|
93
|
+
// accepted when present + non-empty, accepted when absent, rejected
|
|
94
|
+
// when present-but-malformed (the bridge stashes the value verbatim
|
|
95
|
+
// into a Set<string> — empty strings or wrong types would poison
|
|
96
|
+
// the matcher).
|
|
97
|
+
it('accepts an optional rule when behavior is allow', () => {
|
|
98
|
+
expect(validateGatewayMessage({
|
|
99
|
+
type: 'permission', requestId: 'r', behavior: 'allow', rule: 'Edit',
|
|
100
|
+
})).toBe(true)
|
|
101
|
+
expect(validateGatewayMessage({
|
|
102
|
+
type: 'permission', requestId: 'r', behavior: 'allow', rule: 'Skill(mail)',
|
|
103
|
+
})).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('rejects an empty-string rule', () => {
|
|
107
|
+
expect(validateGatewayMessage({
|
|
108
|
+
type: 'permission', requestId: 'r', behavior: 'allow', rule: '',
|
|
109
|
+
})).toBe(false)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('rejects a non-string rule', () => {
|
|
113
|
+
expect(validateGatewayMessage({
|
|
114
|
+
type: 'permission', requestId: 'r', behavior: 'allow', rule: 42,
|
|
115
|
+
})).toBe(false)
|
|
116
|
+
})
|
|
91
117
|
})
|
|
92
118
|
|
|
93
119
|
describe('status', () => {
|
|
@@ -68,7 +68,13 @@ describe('gateway secret-detect intercept — structural wiring', () => {
|
|
|
68
68
|
// Rewrites effectiveText so the broadcast carries the redacted text.
|
|
69
69
|
expect(tail).toMatch(/effectiveText = pipeRes\.rewritten_text/)
|
|
70
70
|
// Deletes the original Telegram message containing the raw bytes.
|
|
71
|
-
|
|
71
|
+
// 2026-05-12: migrated from raw `bot.api.deleteMessage` to the
|
|
72
|
+
// `deleteSensitiveMessage` helper so failures surface to the
|
|
73
|
+
// operator via in-chat warning instead of being silently
|
|
74
|
+
// swallowed. See
|
|
75
|
+
// tests/secret-detect-delete-must-surface-failures.test.ts for
|
|
76
|
+
// the full contract pin.
|
|
77
|
+
expect(tail).toMatch(/deleteSensitiveMessage\(chat_id, msgId, 'detected secret'\)/)
|
|
72
78
|
// Tells the user what was captured (masked).
|
|
73
79
|
expect(tail).toMatch(/captured \$\{pipeRes\.stored\.length\} secret/)
|
|
74
80
|
// Surfaces the masked form (s.masked is computed via maskToken in the pipeline).
|
|
@@ -92,8 +98,11 @@ describe('gateway secret-detect intercept — structural wiring', () => {
|
|
|
92
98
|
expect(tail).toMatch(/deferredSecrets\.set\(/)
|
|
93
99
|
expect(tail).toMatch(/suggested_slug:/)
|
|
94
100
|
// 2. The original message is deleted (so the raw bytes are scrubbed
|
|
95
|
-
// from the chat client even before the user reacts).
|
|
96
|
-
|
|
101
|
+
// from the chat client even before the user reacts). 2026-05-12:
|
|
102
|
+
// migrated to deleteSensitiveMessage so failures surface to the
|
|
103
|
+
// operator via in-chat warning. See
|
|
104
|
+
// tests/secret-detect-delete-must-surface-failures.test.ts.
|
|
105
|
+
expect(tail).toMatch(/deleteSensitiveMessage\(chat_id, msgId, 'detected secret'\)/)
|
|
97
106
|
// 4. The new inline keyboard helper is used in lieu of the legacy
|
|
98
107
|
// plain-text "run /vault list" warning.
|
|
99
108
|
expect(tail).toMatch(/buildDeferredSecretKeyboard\(/)
|