alvin-bot 4.18.0 → 4.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/AEC-PLUGINS-SOURCES.md +53 -0
  2. package/CHANGELOG.md +37 -2
  3. package/DESIGN-SKILLS-SOURCES.md +81 -0
  4. package/bin/cli.js +1 -1
  5. package/dist/providers/claude-sdk-provider.js +24 -0
  6. package/package.json +3 -1
  7. package/test/allowed-users-gate.test.ts +0 -98
  8. package/test/alvin-dispatch.test.ts +0 -220
  9. package/test/async-agent-chunk-flow.test.ts +0 -244
  10. package/test/async-agent-parser-staleness.test.ts +0 -412
  11. package/test/async-agent-parser-streamjson.test.ts +0 -273
  12. package/test/async-agent-parser.test.ts +0 -322
  13. package/test/async-agent-watcher.test.ts +0 -229
  14. package/test/background-bypass-integration.test.ts +0 -443
  15. package/test/background-bypass-stress.test.ts +0 -417
  16. package/test/background-bypass.test.ts +0 -127
  17. package/test/browser-webfetch.test.ts +0 -121
  18. package/test/claude-sdk-provider.test.ts +0 -115
  19. package/test/claude-sdk-tool-use-id.test.ts +0 -180
  20. package/test/console-timestamps.test.ts +0 -98
  21. package/test/cron-progress-ticker.test.ts +0 -76
  22. package/test/cron-restart-resilience.test.ts +0 -191
  23. package/test/cron-run-resolver.test.ts +0 -133
  24. package/test/cron-runjobnow-throw.test.ts +0 -100
  25. package/test/debounce.test.ts +0 -60
  26. package/test/delivery-registry.test.ts +0 -71
  27. package/test/exec-guard-metachars.test.ts +0 -110
  28. package/test/file-permissions.test.ts +0 -130
  29. package/test/i18n.test.ts +0 -108
  30. package/test/list-subagents-merged.test.ts +0 -172
  31. package/test/memory-extractor.test.ts +0 -151
  32. package/test/memory-layers.test.ts +0 -169
  33. package/test/memory-sdk-injection.test.ts +0 -146
  34. package/test/memory-stress-restart.test.ts +0 -337
  35. package/test/multi-session-stress.test.ts +0 -255
  36. package/test/platform-session-key.test.ts +0 -69
  37. package/test/process-manager.test.ts +0 -186
  38. package/test/registry.test.ts +0 -201
  39. package/test/session-pending-background.test.ts +0 -59
  40. package/test/session-persistence.test.ts +0 -195
  41. package/test/slack-progress-ticker.test.ts +0 -123
  42. package/test/slack-slash-command.test.ts +0 -61
  43. package/test/slack-test-connection.test.ts +0 -176
  44. package/test/stress-scenarios.test.ts +0 -356
  45. package/test/stuck-timer.test.ts +0 -116
  46. package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
  47. package/test/subagent-delivery-platform-routing.test.ts +0 -232
  48. package/test/subagent-delivery.test.ts +0 -273
  49. package/test/subagent-final-text.test.ts +0 -132
  50. package/test/subagent-stats.test.ts +0 -119
  51. package/test/subagent-toolset-allowlist.test.ts +0 -146
  52. package/test/subagents-commands.test.ts +0 -64
  53. package/test/subagents-config.test.ts +0 -114
  54. package/test/subagents-depth.test.ts +0 -58
  55. package/test/subagents-inheritance.test.ts +0 -67
  56. package/test/subagents-name-resolver.test.ts +0 -122
  57. package/test/subagents-priority-reject.test.ts +0 -88
  58. package/test/subagents-queue.test.ts +0 -127
  59. package/test/subagents-shutdown.test.ts +0 -126
  60. package/test/subagents-toolset.test.ts +0 -71
  61. package/test/sync-task-timeout.test.ts +0 -153
  62. package/test/system-prompt-background-hint.test.ts +0 -65
  63. package/test/telegram-error-filter.test.ts +0 -85
  64. package/test/telegram-workspace-command.test.ts +0 -78
  65. package/test/timing-safe-bearer.test.ts +0 -65
  66. package/test/watchdog-brake.test.ts +0 -157
  67. package/test/watcher-pending-count.test.ts +0 -228
  68. package/test/watcher-zombie-fix.test.ts +0 -252
  69. package/test/web-server-integration.test.ts +0 -189
  70. package/test/web-server-resilience.test.ts +0 -118
  71. package/test/web-server-shutdown.test.ts +0 -117
  72. package/test/whatsapp-auth-resilience.test.ts +0 -96
  73. package/test/workspaces.test.ts +0 -196
  74. package/vitest.config.ts +0 -17
@@ -1,153 +0,0 @@
1
- /**
2
- * v4.12.1 — Integration test: sync Agent tool call with long silence
3
- * does NOT trigger the stuck timeout abort.
4
- *
5
- * Before v4.12.1: a Task tool call WITHOUT run_in_background: true
6
- * running silently for >10 minutes triggered STUCK_TIMEOUT_MS and
7
- * aborted the main session — even though the sub-agent was working
8
- * legitimately (it just can't emit intermediate chunks to the parent
9
- * stream).
10
- *
11
- * After v4.12.1: the stuck timer escalates to SYNC_AGENT_IDLE_TIMEOUT_MS
12
- * (120 min) as soon as the sync tool_use is detected (tracked by
13
- * toolUseId), and only reverts to the normal timeout after the matching
14
- * tool_result arrives.
15
- *
16
- * This test uses the pure createStuckTimer state machine directly —
17
- * the real integration into the message handler's for-await loop is
18
- * covered by the Task A unit tests and manual smoke tests. What this
19
- * file verifies is the COMBINED flow (normal → enterSync → exitSync →
20
- * normal) over realistic timing scales.
21
- */
22
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
23
- import { createStuckTimer } from "../src/handlers/stuck-timer.js";
24
-
25
- describe("sync Task tool call stuck-timer integration (v4.12.1)", () => {
26
- beforeEach(() => vi.useFakeTimers());
27
- afterEach(() => vi.useRealTimers());
28
-
29
- it("30-min silent sync Task gap does NOT fire the 10-min normal timer", () => {
30
- const onTimeout = vi.fn();
31
- const t = createStuckTimer({
32
- normalMs: 10 * 60 * 1000, // 10 min — production default
33
- extendedMs: 120 * 60 * 1000, // 120 min — production default
34
- onTimeout,
35
- });
36
-
37
- // Simulate: handler begins streaming, first chunk arrives
38
- t.reset();
39
-
40
- // Assistant text chunk arrives
41
- t.reset();
42
-
43
- // tool_use with Task, runInBackground NOT true → sync path
44
- t.enterSync("toolu_sync_123");
45
-
46
- // 30 min of silence (no chunks, no resets) — sub-agent is working
47
- vi.advanceTimersByTime(30 * 60 * 1000);
48
-
49
- // MUST NOT have fired — we're in extended mode (120 min cap)
50
- expect(onTimeout).not.toHaveBeenCalled();
51
-
52
- // tool_result finally arrives
53
- t.exitSync("toolu_sync_123");
54
- t.reset();
55
-
56
- // Subsequent 10 minutes of silence SHOULD fire (back to normal mode)
57
- vi.advanceTimersByTime(10 * 60 * 1000);
58
- expect(onTimeout).toHaveBeenCalledTimes(1);
59
- });
60
-
61
- it("async Task (runInBackground=true) uses normal timeout (handler does NOT call enterSync)", () => {
62
- // Simulates the decision flow: the handler only calls enterSync
63
- // when chunk.runInBackground !== true. For async tasks, enterSync
64
- // is NEVER called, so the normal 10-min timer applies to any gap
65
- // before the watcher delivers (which is a separate path).
66
- const onTimeout = vi.fn();
67
- const t = createStuckTimer({
68
- normalMs: 10 * 60 * 1000,
69
- extendedMs: 120 * 60 * 1000,
70
- onTimeout,
71
- });
72
-
73
- t.reset();
74
- // Async path: the async tool_result arrives almost immediately
75
- // (the SDK returns "Async agent launched successfully" quickly)
76
- t.reset();
77
- // Then the parent turn ends normally within a few seconds
78
- // ... but if something went wrong and the parent stream hangs,
79
- // the normal 10-min timeout applies:
80
- vi.advanceTimersByTime(11 * 60 * 1000);
81
- expect(onTimeout).toHaveBeenCalledTimes(1);
82
- });
83
-
84
- it("cancel during extended mode stops cleanly (handler finally block)", () => {
85
- const onTimeout = vi.fn();
86
- const t = createStuckTimer({
87
- normalMs: 10 * 60 * 1000,
88
- extendedMs: 120 * 60 * 1000,
89
- onTimeout,
90
- });
91
-
92
- t.enterSync("toolu_1");
93
-
94
- // Simulate: partway through a sync task, something errors out
95
- // and the handler reaches its finally block
96
- vi.advanceTimersByTime(60 * 60 * 1000);
97
- t.cancel();
98
-
99
- // Another 60 min pass — no firing because cancel cleared the timer
100
- vi.advanceTimersByTime(60 * 60 * 1000);
101
- expect(onTimeout).not.toHaveBeenCalled();
102
- });
103
-
104
- it("multiple parallel sync tasks (nested Agent calls): extended until ALL complete", () => {
105
- // Edge case: if two parent-level sync tool_use blocks land in
106
- // the same assistant message, both get tracked. The extended
107
- // timer must stay armed until BOTH exit.
108
- const onTimeout = vi.fn();
109
- const t = createStuckTimer({
110
- normalMs: 10 * 60 * 1000,
111
- extendedMs: 120 * 60 * 1000,
112
- onTimeout,
113
- });
114
-
115
- t.enterSync("toolu_parallel_1");
116
- t.enterSync("toolu_parallel_2");
117
- expect(t._pendingCount()).toBe(2);
118
-
119
- // First finishes
120
- vi.advanceTimersByTime(20 * 60 * 1000);
121
- t.exitSync("toolu_parallel_1");
122
- expect(t._pendingCount()).toBe(1);
123
-
124
- // Second still running — another 30 min of silence
125
- vi.advanceTimersByTime(30 * 60 * 1000);
126
- expect(onTimeout).not.toHaveBeenCalled();
127
-
128
- // Second finishes
129
- t.exitSync("toolu_parallel_2");
130
- t.reset();
131
-
132
- // Now back to normal timeout — should fire after 10 min
133
- vi.advanceTimersByTime(10 * 60 * 1000);
134
- expect(onTimeout).toHaveBeenCalledTimes(1);
135
- });
136
-
137
- it("regression guard: old behavior (no task tracking, flat 10-min) would have false-aborted", () => {
138
- // This test is a documentation-as-code artifact: it simulates
139
- // what the OLD code did and verifies it WOULD have false-aborted.
140
- // If we ever revert the fix, this test will catch the regression
141
- // by asserting the old behavior fires at exactly 10 min of silence.
142
- const onTimeout = vi.fn();
143
- const flatTimer = createStuckTimer({
144
- normalMs: 10 * 60 * 1000,
145
- extendedMs: 10 * 60 * 1000, // identical → simulates pre-v4.12.1 behavior
146
- onTimeout,
147
- });
148
- flatTimer.enterSync("toolu_1");
149
- vi.advanceTimersByTime(10 * 60 * 1000);
150
- // With the flat timer (pre-fix), a 10-min sync gap DOES fire
151
- expect(onTimeout).toHaveBeenCalledTimes(1);
152
- });
153
- });
@@ -1,65 +0,0 @@
1
- /**
2
- * Fix #17 (Stage 1) — buildSystemPrompt must include the async-subagent
3
- * hint for SDK sessions so Claude autonomously uses run_in_background: true
4
- * for long-running tasks, unblocking the main Telegram session.
5
- *
6
- * See docs/superpowers/plans/2026-04-13-async-subagents.md
7
- */
8
- import { describe, it, expect } from "vitest";
9
- import { buildSystemPrompt } from "../src/services/personality.js";
10
-
11
- describe("buildSystemPrompt background-subagent hint (Stage 1)", () => {
12
- it("includes the background hint when isSDK=true", () => {
13
- const prompt = buildSystemPrompt(true, "en", "1234");
14
- expect(prompt).toMatch(/run_in_background/);
15
- expect(prompt.toLowerCase()).toMatch(/background|async/);
16
- });
17
-
18
- it("instructs Claude to wrap up the turn after launching a background agent", () => {
19
- const prompt = buildSystemPrompt(true, "en", "1234");
20
- // Must tell Claude to end the turn quickly, not keep working
21
- expect(prompt.toLowerCase()).toMatch(/end.*turn|wrap up|finish.*turn|end your turn/);
22
- });
23
-
24
- it("lists the criteria for when to use background mode", () => {
25
- const prompt = buildSystemPrompt(true, "en", "1234");
26
- // Must mention at least one concrete trigger
27
- expect(prompt.toLowerCase()).toMatch(/audit|research|long|>.*minute|2 min/);
28
- });
29
-
30
- it("tells Claude NOT to use background for trivial queries", () => {
31
- const prompt = buildSystemPrompt(true, "en", "1234");
32
- expect(prompt.toLowerCase()).toMatch(/don'?t use|avoid|not for|simple question/);
33
- });
34
-
35
- it("skips the hint for non-SDK sessions (no Agent tool available)", () => {
36
- const prompt = buildSystemPrompt(false, "en", "1234");
37
- expect(prompt).not.toMatch(/run_in_background/);
38
- });
39
-
40
- it("hint is present regardless of user UI locale (prompt is always in English for Claude)", () => {
41
- const en = buildSystemPrompt(true, "en", "1234");
42
- const de = buildSystemPrompt(true, "de", "1234");
43
- const es = buildSystemPrompt(true, "es", "1234");
44
- expect(en).toMatch(/run_in_background/);
45
- expect(de).toMatch(/run_in_background/);
46
- expect(es).toMatch(/run_in_background/);
47
- });
48
-
49
- it("uses CRITICAL framing and decision-tree structure (v4.12.1)", () => {
50
- const prompt = buildSystemPrompt(true, "en", "1234");
51
- expect(prompt).toMatch(/CRITICAL/);
52
- expect(prompt).toMatch(/decision tree/i);
53
- });
54
-
55
- it("explicitly warns about Telegram session blocking (v4.12.1)", () => {
56
- const prompt = buildSystemPrompt(true, "en", "1234");
57
- expect(prompt.toLowerCase()).toMatch(/blocked|blocking/);
58
- expect(prompt.toLowerCase()).toMatch(/telegram/);
59
- });
60
-
61
- it("aggressive 30-second threshold (v4.12.1, previously 2 minutes)", () => {
62
- const prompt = buildSystemPrompt(true, "en", "1234");
63
- expect(prompt).toMatch(/30\s*seconds?/i);
64
- });
65
- });
@@ -1,85 +0,0 @@
1
- /**
2
- * Fix #12 — grammy error noise filter.
3
- *
4
- * Regression: chunks like
5
- * Fehler: Call to 'editMessageText' failed! (400: Bad Request:
6
- * message is not modified: specified new message content and reply
7
- * markup are exactly the same as a current content and reply markup
8
- * of the message)
9
- * were being sent to end users 2-3 times per day whenever a live-stream
10
- * edit raced against itself. The v4.8.8 `bot.catch()` fix swallowed
11
- * these at the middleware layer, but `telegram.ts` finalize() and
12
- * `handlers/message.ts` error paths bypass bot.catch completely —
13
- * they surface the raw grammy error via `ctx.reply()`.
14
- *
15
- * Contract: `isHarmlessTelegramError(err)` returns true for:
16
- * - "message is not modified" (any language, any prefix)
17
- * - "Call to 'editMessageText' failed" combined with the above
18
- * - "query is too old" (harmless callback-answer race)
19
- * - "MESSAGE_ID_INVALID" (user deleted the message before we edited it)
20
- *
21
- * Returns false for all other errors — they still need surfacing.
22
- */
23
- import { describe, it, expect } from "vitest";
24
- import { isHarmlessTelegramError } from "../src/util/telegram-error-filter.js";
25
-
26
- describe("isHarmlessTelegramError (Fix #12)", () => {
27
- it("matches the exact production message", () => {
28
- const err = new Error(
29
- "Call to 'editMessageText' failed! (400: Bad Request: message is not modified: " +
30
- "specified new message content and reply markup are exactly the same as a current " +
31
- "content and reply markup of the message)",
32
- );
33
- expect(isHarmlessTelegramError(err)).toBe(true);
34
- });
35
-
36
- it("matches just the 'message is not modified' substring", () => {
37
- expect(isHarmlessTelegramError(new Error("400: message is not modified"))).toBe(true);
38
- });
39
-
40
- it("matches 'specified new message content ... exactly the same'", () => {
41
- expect(
42
- isHarmlessTelegramError(
43
- new Error("specified new message content and reply markup are exactly the same"),
44
- ),
45
- ).toBe(true);
46
- });
47
-
48
- it("matches 'query is too old' (answerCallbackQuery race)", () => {
49
- expect(
50
- isHarmlessTelegramError(new Error("Bad Request: query is too old and response timeout expired")),
51
- ).toBe(true);
52
- });
53
-
54
- it("matches 'message to edit not found' (user deleted)", () => {
55
- expect(
56
- isHarmlessTelegramError(new Error("Bad Request: message to edit not found")),
57
- ).toBe(true);
58
- });
59
-
60
- it("matches MESSAGE_ID_INVALID", () => {
61
- expect(isHarmlessTelegramError(new Error("Bad Request: MESSAGE_ID_INVALID"))).toBe(true);
62
- });
63
-
64
- it("accepts plain strings as well as Error objects", () => {
65
- expect(isHarmlessTelegramError("message is not modified")).toBe(true);
66
- });
67
-
68
- it("accepts undefined / null as not harmless (caller decides)", () => {
69
- expect(isHarmlessTelegramError(undefined)).toBe(false);
70
- expect(isHarmlessTelegramError(null)).toBe(false);
71
- });
72
-
73
- it("does NOT swallow real errors", () => {
74
- expect(isHarmlessTelegramError(new Error("Unauthorized"))).toBe(false);
75
- expect(isHarmlessTelegramError(new Error("Too Many Requests: retry after 5"))).toBe(false);
76
- expect(isHarmlessTelegramError(new Error("chat not found"))).toBe(false);
77
- expect(isHarmlessTelegramError(new Error("stream error: provider timeout"))).toBe(false);
78
- });
79
-
80
- it("handles nested err.description from grammy", () => {
81
- const err = new Error("anything") as Error & { description?: string };
82
- err.description = "Bad Request: message is not modified";
83
- expect(isHarmlessTelegramError(err)).toBe(true);
84
- });
85
- });
@@ -1,78 +0,0 @@
1
- /**
2
- * v4.12.0 — Telegram /workspace command + workspace-aware session key.
3
- */
4
- import { describe, it, expect, beforeEach, vi } from "vitest";
5
- import fs from "fs";
6
- import os from "os";
7
- import { resolve } from "path";
8
-
9
- const TEST_DATA_DIR = resolve(os.tmpdir(), `alvin-tgws-${process.pid}-${Date.now()}`);
10
-
11
- beforeEach(() => {
12
- if (fs.existsSync(TEST_DATA_DIR)) fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
13
- fs.mkdirSync(resolve(TEST_DATA_DIR, "workspaces"), { recursive: true });
14
- process.env.ALVIN_DATA_DIR = TEST_DATA_DIR;
15
- vi.resetModules();
16
- });
17
-
18
- describe("Telegram workspace state (v4.12.0)", () => {
19
- it("getTelegramWorkspace returns null by default", async () => {
20
- const { getTelegramWorkspace } = await import("../src/services/session.js");
21
- expect(getTelegramWorkspace("42")).toBeNull();
22
- });
23
-
24
- it("setTelegramWorkspace stores the name", async () => {
25
- const { getTelegramWorkspace, setTelegramWorkspace } = await import("../src/services/session.js");
26
- setTelegramWorkspace("42", "my-project");
27
- expect(getTelegramWorkspace("42")).toBe("my-project");
28
- });
29
-
30
- it("setTelegramWorkspace(userId, null) clears the mapping", async () => {
31
- const { getTelegramWorkspace, setTelegramWorkspace } = await import("../src/services/session.js");
32
- setTelegramWorkspace("42", "my-project");
33
- setTelegramWorkspace("42", null);
34
- expect(getTelegramWorkspace("42")).toBeNull();
35
- });
36
-
37
- it("persistence: setTelegramWorkspace + flush + reload roundtrips", async () => {
38
- const { setTelegramWorkspace, attachPersistHook } = await import("../src/services/session.js");
39
- const { flushSessions, schedulePersist } = await import("../src/services/session-persistence.js");
40
- attachPersistHook(schedulePersist);
41
-
42
- setTelegramWorkspace("42", "my-project");
43
- setTelegramWorkspace("99", "homes");
44
- await flushSessions();
45
-
46
- vi.resetModules();
47
- const s2 = await import("../src/services/session.js");
48
- const p2 = await import("../src/services/session-persistence.js");
49
- p2.loadPersistedSessions();
50
-
51
- expect(s2.getTelegramWorkspace("42")).toBe("my-project");
52
- expect(s2.getTelegramWorkspace("99")).toBe("homes");
53
- });
54
-
55
- it("legacy flat session file still loads (backwards compat)", async () => {
56
- fs.mkdirSync(resolve(TEST_DATA_DIR, "state"), { recursive: true });
57
- fs.writeFileSync(
58
- resolve(TEST_DATA_DIR, "state", "sessions.json"),
59
- JSON.stringify({
60
- "legacy-user": {
61
- sessionId: "abc",
62
- history: [{ role: "user", content: "from v4.11 era" }],
63
- language: "en",
64
- effort: "medium",
65
- voiceReply: false,
66
- workingDir: "/tmp",
67
- },
68
- }),
69
- );
70
-
71
- const { loadPersistedSessions } = await import("../src/services/session-persistence.js");
72
- const { getSession } = await import("../src/services/session.js");
73
- const loaded = loadPersistedSessions();
74
- expect(loaded).toBe(1);
75
- expect(getSession("legacy-user").sessionId).toBe("abc");
76
- expect(getSession("legacy-user").history[0].content).toBe("from v4.11 era");
77
- });
78
- });
@@ -1,65 +0,0 @@
1
- /**
2
- * v4.12.2 — Timing-safe bearer token comparison.
3
- *
4
- * The webhook auth check at src/web/server.ts:127 previously used naive
5
- * string equality on the Authorization header. That's vulnerable (in
6
- * principle) to timing side-channel attacks where an attacker measures
7
- * response times to leak the token character by character.
8
- *
9
- * Real-world exploitability over network is low due to jitter, but
10
- * crypto.timingSafeEqual is the right tool regardless.
11
- *
12
- * This test covers the pure helper; the integration is in server.ts.
13
- */
14
- import { describe, it, expect } from "vitest";
15
- import { timingSafeBearerMatch } from "../src/services/timing-safe-bearer.js";
16
-
17
- describe("timing-safe bearer token comparison (v4.12.2)", () => {
18
- it("matches a correct token", () => {
19
- expect(timingSafeBearerMatch("Bearer abc123xyz", "abc123xyz")).toBe(true);
20
- });
21
-
22
- it("rejects an incorrect token", () => {
23
- expect(timingSafeBearerMatch("Bearer wrong", "abc123xyz")).toBe(false);
24
- });
25
-
26
- it("rejects when Bearer prefix is missing", () => {
27
- expect(timingSafeBearerMatch("abc123xyz", "abc123xyz")).toBe(false);
28
- });
29
-
30
- it("rejects when auth header is empty", () => {
31
- expect(timingSafeBearerMatch("", "abc123xyz")).toBe(false);
32
- });
33
-
34
- it("rejects when auth header is undefined", () => {
35
- expect(timingSafeBearerMatch(undefined, "abc123xyz")).toBe(false);
36
- });
37
-
38
- it("rejects when expected token is empty (prevents accidental auth bypass)", () => {
39
- expect(timingSafeBearerMatch("Bearer anything", "")).toBe(false);
40
- expect(timingSafeBearerMatch("Bearer ", "")).toBe(false);
41
- expect(timingSafeBearerMatch("", "")).toBe(false);
42
- });
43
-
44
- it("rejects tokens of different lengths without revealing prefix match", () => {
45
- expect(timingSafeBearerMatch("Bearer abc", "abcdefg")).toBe(false);
46
- expect(timingSafeBearerMatch("Bearer abcdefg", "abc")).toBe(false);
47
- });
48
-
49
- it("handles unicode tokens (not that we'd use them, but correctness)", () => {
50
- expect(timingSafeBearerMatch("Bearer 🔒xyz", "🔒xyz")).toBe(true);
51
- expect(timingSafeBearerMatch("Bearer 🔒xyz", "🔒xYz")).toBe(false);
52
- });
53
-
54
- it("case-sensitive comparison (tokens are opaque)", () => {
55
- expect(timingSafeBearerMatch("Bearer AbCdEf", "abcdef")).toBe(false);
56
- expect(timingSafeBearerMatch("Bearer AbCdEf", "AbCdEf")).toBe(true);
57
- });
58
-
59
- it("rejects Bearer with leading/trailing whitespace mismatches the expected format", () => {
60
- // RFC 6750 says: Authorization: Bearer <token>
61
- // Exactly one space between "Bearer" and the token.
62
- expect(timingSafeBearerMatch("Bearer abc", "abc")).toBe(false); // double space
63
- expect(timingSafeBearerMatch(" Bearer abc", "abc")).toBe(false); // leading space
64
- });
65
- });
@@ -1,157 +0,0 @@
1
- /**
2
- * Fix #4 — Watchdog brake must actually engage on chronic crashes.
3
- *
4
- * Regression: the previous logic reset crashCount after 5 min of clean
5
- * uptime. Production logs showed the bot crashing ~5 times per hour, but
6
- * each boot lived just long enough (>5 min, <10 min) to reset the counter.
7
- * Result: `crashCount` never reached the brake threshold, the bot cycled
8
- * for hours, and the daily job-alert silently lost its scheduled runs.
9
- *
10
- * New contract (pure function pair extracted to watchdog-brake.ts):
11
- *
12
- * decideBrakeAction(prevBeacon, now, opts)
13
- * - returns `{ action: "proceed", crashCount, crashWindowStart }`
14
- * on clean start or old previous beacon
15
- * - returns `{ action: "proceed", crashCount: N }` when the last run
16
- * exited recently but we're still under the brake threshold
17
- * - returns `{ action: "brake", reason }` when either
18
- * (a) N+1 crashes in a short window, or
19
- * (b) the daily crash cap (default 20) is exceeded
20
- *
21
- * shouldResetCrashCounter(uptimeMs, opts) → boolean
22
- * - default policy: only reset after 1 h of clean uptime (NOT 5 min)
23
- */
24
- import { describe, it, expect } from "vitest";
25
- import {
26
- decideBrakeAction,
27
- shouldResetCrashCounter,
28
- DEFAULTS,
29
- type BeaconData,
30
- } from "../src/services/watchdog-brake.js";
31
-
32
- const ONE_MIN = 60_000;
33
- const ONE_HOUR = 60 * ONE_MIN;
34
-
35
- function beacon(partial: Partial<BeaconData> = {}): BeaconData {
36
- return {
37
- lastBeat: 0,
38
- pid: 1,
39
- bootTime: 0,
40
- crashCount: 0,
41
- crashWindowStart: 0,
42
- dailyCrashCount: 0,
43
- dailyCrashWindowStart: 0,
44
- version: "test",
45
- ...partial,
46
- };
47
- }
48
-
49
- describe("decideBrakeAction (Fix #4)", () => {
50
- it("proceeds on first boot (no previous beacon)", () => {
51
- const now = 1_000_000;
52
- const result = decideBrakeAction(null, now);
53
- expect(result.action).toBe("proceed");
54
- if (result.action === "proceed") {
55
- expect(result.crashCount).toBe(0);
56
- expect(result.crashWindowStart).toBe(now);
57
- expect(result.dailyCrashCount).toBe(0);
58
- }
59
- });
60
-
61
- it("proceeds when previous beacon is old (>STALE_MS) — clean exit", () => {
62
- const now = 1_000_000_000;
63
- const prev = beacon({ lastBeat: now - 10 * ONE_MIN, crashCount: 3 });
64
- const result = decideBrakeAction(prev, now);
65
- expect(result.action).toBe("proceed");
66
- if (result.action === "proceed") {
67
- // Old beacon → treat as clean, reset window counter (but keep daily)
68
- expect(result.crashCount).toBe(0);
69
- }
70
- });
71
-
72
- it("counts a restart after a fresh beacon as a crash", () => {
73
- const now = 1_000_000_000;
74
- const prev = beacon({
75
- lastBeat: now - 15_000, // 15 s ago
76
- crashCount: 2,
77
- crashWindowStart: now - 5 * ONE_MIN,
78
- dailyCrashCount: 2,
79
- dailyCrashWindowStart: now - 2 * ONE_HOUR,
80
- });
81
- const result = decideBrakeAction(prev, now);
82
- expect(result.action).toBe("proceed");
83
- if (result.action === "proceed") {
84
- expect(result.crashCount).toBe(3);
85
- expect(result.dailyCrashCount).toBe(3);
86
- }
87
- });
88
-
89
- it("engages brake when short-window threshold is crossed", () => {
90
- const now = 1_000_000_000;
91
- const prev = beacon({
92
- lastBeat: now - 10_000,
93
- crashCount: DEFAULTS.SHORT_BRAKE_THRESHOLD - 1, // one more = brake
94
- crashWindowStart: now - 2 * ONE_MIN,
95
- dailyCrashCount: 5,
96
- dailyCrashWindowStart: now - ONE_HOUR,
97
- });
98
- const result = decideBrakeAction(prev, now);
99
- expect(result.action).toBe("brake");
100
- if (result.action === "brake") {
101
- expect(result.reason).toMatch(/short.*window|threshold|crashes/i);
102
- }
103
- });
104
-
105
- it("engages brake when daily cap is exceeded", () => {
106
- const now = 1_000_000_000;
107
- const prev = beacon({
108
- lastBeat: now - 10_000,
109
- crashCount: 1, // short window fine
110
- crashWindowStart: now - 30 * ONE_MIN,
111
- dailyCrashCount: DEFAULTS.DAILY_BRAKE_THRESHOLD - 1,
112
- dailyCrashWindowStart: now - 12 * ONE_HOUR,
113
- });
114
- const result = decideBrakeAction(prev, now);
115
- expect(result.action).toBe("brake");
116
- if (result.action === "brake") {
117
- expect(result.reason).toMatch(/daily|day/i);
118
- }
119
- });
120
-
121
- it("rolls over daily counter when 24h window expires", () => {
122
- const now = 1_000_000_000;
123
- const prev = beacon({
124
- lastBeat: now - 10_000,
125
- crashCount: 1,
126
- crashWindowStart: now - 30 * ONE_MIN,
127
- dailyCrashCount: 18, // high
128
- dailyCrashWindowStart: now - 25 * ONE_HOUR, // but window rolled over
129
- });
130
- const result = decideBrakeAction(prev, now);
131
- expect(result.action).toBe("proceed");
132
- if (result.action === "proceed") {
133
- expect(result.dailyCrashCount).toBe(1); // fresh window
134
- expect(result.dailyCrashWindowStart).toBe(now);
135
- }
136
- });
137
- });
138
-
139
- describe("shouldResetCrashCounter (Fix #4)", () => {
140
- it("does NOT reset after 5 min of uptime (old buggy behaviour)", () => {
141
- expect(shouldResetCrashCounter(5 * ONE_MIN)).toBe(false);
142
- });
143
-
144
- it("does NOT reset after 30 min of uptime", () => {
145
- expect(shouldResetCrashCounter(30 * ONE_MIN)).toBe(false);
146
- });
147
-
148
- it("resets after 1 h of clean uptime", () => {
149
- expect(shouldResetCrashCounter(ONE_HOUR)).toBe(true);
150
- expect(shouldResetCrashCounter(ONE_HOUR + 1)).toBe(true);
151
- });
152
-
153
- it("can be overridden via opts.resetAfterMs", () => {
154
- expect(shouldResetCrashCounter(10 * ONE_MIN, { resetAfterMs: 10 * ONE_MIN })).toBe(true);
155
- expect(shouldResetCrashCounter(10 * ONE_MIN - 1, { resetAfterMs: 10 * ONE_MIN })).toBe(false);
156
- });
157
- });