switchroom 0.21.7 → 0.21.9
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/bin/tmp-reaper.sh +234 -0
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +2 -2
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +3520 -2782
- package/dist/host-control/main.js +177 -13
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +6 -5
- package/profiles/_base/start.sh.hbs +115 -0
- package/profiles/_shared/local-time.md.hbs +6 -0
- package/profiles/default/CLAUDE.md.hbs +0 -12
- package/skills/switchroom-architecture/telegram.md +12 -10
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/README.md +3 -1
- package/telegram-plugin/dist/gateway/gateway.js +1164 -547
- package/telegram-plugin/format.ts +12 -4
- package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
- package/telegram-plugin/gateway/approval-hold.ts +32 -1
- package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
- package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
- package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
- package/telegram-plugin/gateway/gateway.ts +12 -10
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
- package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
- package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
- package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/render/code-segments.ts +38 -4
- package/telegram-plugin/render/dollar-math-guard.ts +16 -1
- package/telegram-plugin/render/ir.ts +53 -3
- package/telegram-plugin/render/parse.ts +73 -14
- package/telegram-plugin/render/render.ts +53 -15
- package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
- package/telegram-plugin/rich-send.ts +22 -7
- package/telegram-plugin/shared/bot-runtime.ts +3 -2
- package/telegram-plugin/telegraph.ts +6 -4
- package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
- package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
- package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
- package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
- package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
- package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
- package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
- package/telegram-plugin/tests/render/parse.test.ts +30 -5
- package/telegram-plugin/tests/render/render.test.ts +9 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
- package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
- package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
- package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
- package/telegram-plugin/tests/telegraph.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
|
@@ -3,44 +3,55 @@
|
|
|
3
3
|
//
|
|
4
4
|
// ── Root cause ───────────────────────────────────────────────────────────
|
|
5
5
|
// Assistant replies are composed by a model that habitually emits constructs
|
|
6
|
-
// from OTHER surfaces (GitHub / Obsidian / LaTeX)
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
6
|
+
// from OTHER surfaces (GitHub / Obsidian / LaTeX). Most of them turn out to be
|
|
7
|
+
// natively supported by Telegram's rich markdown path (wire-verified
|
|
8
|
+
// 2026-08-13 by sending raw `sendRichMessage` probes and reading back the
|
|
9
|
+
// echoed `rich_message.blocks`):
|
|
10
|
+
// • `<details open><summary>S</summary>…</details>` → a real typed
|
|
11
|
+
// `details` node (native collapsible) — SUPPORTED, must pass through.
|
|
12
|
+
// • `$x^2+y^2$` → a `mathematical_expression` node — SUPPORTED (and
|
|
13
|
+
// protected from the sibling guards via `splitProtectedSegments`'s
|
|
14
|
+
// compact-math-span rule; see code-segments.ts).
|
|
15
|
+
// • footnotes `claim[^1]` + `[^1]: body` → full superscript/anchor/
|
|
16
|
+
// reference_link/footer machinery — SUPPORTED, must pass through.
|
|
17
|
+
// • `<sub>`/`<sup>`/`<u>`, `<aside>…<cite>…</cite></aside>`, `tg://time`
|
|
18
|
+
// links, `- [ ]` task lists — all SUPPORTED, never touched here.
|
|
19
|
+
// (An earlier revision of this guard "repaired" `<details>` into a `**> `
|
|
20
|
+
// expandable blockquote and deleted footnote reference markers. Both repairs
|
|
21
|
+
// were built on a false belief: `**>` is MarkdownV2 syntax that the rich
|
|
22
|
+
// markdown path renders as LITERAL `**>` paragraph text — the probe proved the
|
|
23
|
+
// conversion turned a SUPPORTED construct into an UNSUPPORTED one. That logic
|
|
24
|
+
// is deleted, not gated.)
|
|
25
|
+
//
|
|
26
|
+
// What genuinely does NOT render and still needs repair: the caret
|
|
27
|
+
// highlight/superscript shorthand `^…^`. Telegram's rich markdown has no caret
|
|
28
|
+
// syntax — the carets render literally on the reader's screen. The resident
|
|
29
|
+
// floor card tells the model to use `<sup>…</sup>` instead, but prompt
|
|
30
|
+
// discipline is not a guarantee; this guard makes the repair deterministic at
|
|
31
|
+
// send time.
|
|
13
32
|
//
|
|
14
33
|
// ── What it repairs (deterministic, pure string transform) ─────────────────
|
|
15
|
-
// •
|
|
16
|
-
// blockquote (`**> Title` first line + `> …` continuation) — the native
|
|
17
|
-
// equivalent of a collapsible. `<details>` without a `<summary>` folds the
|
|
18
|
-
// whole body into an expandable blockquote. Any orphan `<details>` /
|
|
19
|
-
// `</details>` / `<summary>` tags left over are stripped.
|
|
20
|
-
// • `^highlight^` / `x^2^` caret pairs → the inner text, carets removed
|
|
21
|
-
// (Telegram has no highlight/superscript; the carets render literally).
|
|
22
|
-
// • Footnote reference markers `[^id]` → removed (Telegram has no footnotes).
|
|
23
|
-
// A footnote DEFINITION line `[^id]: …` is left alone (the `]:` lookahead).
|
|
34
|
+
// • `^highlight^` / `x^2^` caret pairs → the inner text, carets removed.
|
|
24
35
|
//
|
|
25
36
|
// ── What it deliberately does NOT touch ────────────────────────────────────
|
|
26
|
-
// • `$…$` math:
|
|
27
|
-
// `
|
|
28
|
-
//
|
|
29
|
-
// double-process and risk corrupting currency prose, so this guard leaves
|
|
30
|
-
// `$` untouched by design. Math repair is COVERED, just in the sibling guard.
|
|
37
|
+
// • `$…$` math: a compact math span is PROTECTED upstream (a `code: true`
|
|
38
|
+
// segment from `splitProtectedSegments`), and accidental currency `$` is
|
|
39
|
+
// owned by `guardDollarMath` (disjoint char set).
|
|
31
40
|
// • `~sub~` tilde pairs: the strikethrough/tilde trigger is owned by
|
|
32
41
|
// `guardAccidentalInlinePairs` (disjoint char set). This guard never
|
|
33
42
|
// inspects or inserts `~`.
|
|
34
43
|
// • `__underline__`: renders as BOLD in Telegram — legible, not broken — so it
|
|
35
44
|
// is left as-is (the floor card asks the model to avoid it, but there is no
|
|
36
45
|
// glyph-level failure to repair).
|
|
46
|
+
// • footnote markers `[^id]` / definitions `[^id]: …`: natively supported,
|
|
47
|
+
// pass through verbatim. (The caret regex below can never touch them: the
|
|
48
|
+
// `]` / `:` break the alphanumeric-only inner run.)
|
|
37
49
|
//
|
|
38
|
-
// Code spans / fenced blocks / link destinations / table rows are
|
|
39
|
-
// verbatim (shared `splitProtectedSegments`). A strict no-op for any
|
|
40
|
-
// without
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
// accidental-formatting guards.
|
|
50
|
+
// Code spans / fenced blocks / link destinations / math spans / table rows are
|
|
51
|
+
// emitted verbatim (shared `splitProtectedSegments`). A strict no-op for any
|
|
52
|
+
// body without a caret pair, and idempotent (a stripped caret pair contains no
|
|
53
|
+
// `^`). Safe to compose once per send alongside the #3252 accidental-formatting
|
|
54
|
+
// guards.
|
|
44
55
|
|
|
45
56
|
import { splitProtectedSegments } from "./code-segments.js";
|
|
46
57
|
|
|
@@ -56,69 +67,23 @@ import { splitProtectedSegments } from "./code-segments.js";
|
|
|
56
67
|
* permissive inner run the first two carets of `a^2+b^2=c^2` pair up (`^2+b^`)
|
|
57
68
|
* and get stripped, mangling the math; requiring the inner run to be pure
|
|
58
69
|
* alphanumerics means `^2+b^` never matches (the `+` breaks the run), so
|
|
59
|
-
* `a^2+b^2=c^2`, `2^8`, and `x^n` all pass through untouched.
|
|
70
|
+
* `a^2+b^2=c^2`, `2^8`, and `x^n` all pass through untouched. It also keeps
|
|
71
|
+
* the guard off footnote markers `[^1]` (the `]` breaks the run). */
|
|
60
72
|
const CARET_PAIR = /\^([A-Za-z0-9]+)\^/g;
|
|
61
73
|
|
|
62
|
-
/** Footnote reference marker `[^id]` where the id is a short alphanumeric run
|
|
63
|
-
* (`[^1]`, `[^note]`, `[^ref]`) NOT immediately followed by `:` (which would
|
|
64
|
-
* make it a footnote DEFINITION line we leave intact). Removed entirely.
|
|
65
|
-
* Requiring the id to be 1–10 ALPHANUMERIC chars keeps this off regex-ish
|
|
66
|
-
* literals like `[^/]`, `[^\s]`, `[^-a-z]`, whose bodies contain punctuation
|
|
67
|
-
* and so never match. In-prose subscript-ish `array[^i]` outside a code span
|
|
68
|
-
* is a rare theoretical false positive (an `i` id matches); code spans are
|
|
69
|
-
* masked upstream by splitProtectedSegments so real code is safe, and prose
|
|
70
|
-
* that writes a literal `[^i]` reads as footnote noise anyway. */
|
|
71
|
-
const FOOTNOTE_MARKER = /\[\^[A-Za-z0-9]{1,10}\](?!:)/g;
|
|
72
|
-
|
|
73
|
-
/** `<details>…</details>` with an optional leading `<summary>…</summary>`.
|
|
74
|
-
* Dot-all via `[\s\S]`; non-greedy so adjacent blocks don't merge. */
|
|
75
|
-
const DETAILS_BLOCK =
|
|
76
|
-
/<details[^>]*>\s*(?:<summary[^>]*>([\s\S]*?)<\/summary>)?([\s\S]*?)<\/details>/gi;
|
|
77
|
-
|
|
78
|
-
/** Orphan collapsible tags left after DETAILS_BLOCK (malformed / unpaired). */
|
|
79
|
-
const ORPHAN_TAGS = /<\/?(?:details|summary)[^>]*>/gi;
|
|
80
|
-
|
|
81
|
-
/** Fold `title` + `body` into a Telegram expandable blockquote: the first
|
|
82
|
-
* emitted line carries the `**> ` marker (switchroom's expandable-blockquote
|
|
83
|
-
* encoding — see parse.ts), every subsequent line a plain `> `. */
|
|
84
|
-
function toExpandableBlockquote(title: string, body: string): string {
|
|
85
|
-
const lines: string[] = [];
|
|
86
|
-
const t = title.trim();
|
|
87
|
-
if (t) lines.push(t);
|
|
88
|
-
for (const raw of body.split("\n")) {
|
|
89
|
-
const line = raw.trimEnd();
|
|
90
|
-
// Collapse leading/trailing blank lines but keep interior structure.
|
|
91
|
-
if (line.trim() === "" && lines.length === 0) continue;
|
|
92
|
-
lines.push(line);
|
|
93
|
-
}
|
|
94
|
-
// Trim trailing blanks.
|
|
95
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
96
|
-
if (lines.length === 0) return "";
|
|
97
|
-
return lines
|
|
98
|
-
.map((line, i) => (i === 0 ? `**> ${line}` : `> ${line}`))
|
|
99
|
-
.join("\n");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
74
|
/** Repair unsupported tokens in a single PROSE segment. */
|
|
103
75
|
function repairProse(text: string): string {
|
|
104
|
-
|
|
105
|
-
out = out.replace(DETAILS_BLOCK, (_m, summary: string | undefined, body: string) =>
|
|
106
|
-
toExpandableBlockquote(summary ?? "", body ?? ""),
|
|
107
|
-
);
|
|
108
|
-
out = out.replace(ORPHAN_TAGS, "");
|
|
109
|
-
out = out.replace(CARET_PAIR, (_m, inner: string) => inner);
|
|
110
|
-
out = out.replace(FOOTNOTE_MARKER, "");
|
|
111
|
-
return out;
|
|
76
|
+
return text.replace(CARET_PAIR, (_m, inner: string) => inner);
|
|
112
77
|
}
|
|
113
78
|
|
|
114
79
|
/**
|
|
115
|
-
* Neutralise Telegram-unrenderable
|
|
116
|
-
*
|
|
80
|
+
* Neutralise Telegram-unrenderable caret pairs (`^…^`) on the FINAL rendered
|
|
81
|
+
* rich-markdown string. Code / links / math spans / tables are verbatim.
|
|
117
82
|
* Deterministic, idempotent, and a strict no-op absent any target token.
|
|
118
83
|
*/
|
|
119
84
|
export function guardUnsupportedTokens(text: string): string {
|
|
120
|
-
// Cheap pre-check: nothing to do unless a
|
|
121
|
-
if (
|
|
85
|
+
// Cheap pre-check: nothing to do unless a caret is present at all.
|
|
86
|
+
if (!text.includes("^")) return text;
|
|
122
87
|
return splitProtectedSegments(text)
|
|
123
88
|
.map((seg) => (seg.code ? seg.text : repairProse(seg.text)))
|
|
124
89
|
.join("");
|
|
@@ -54,16 +54,31 @@ export interface InputRichMessageMarkdown {
|
|
|
54
54
|
* guards is disjoint in the characters it inspects AND the characters it
|
|
55
55
|
* inserts (`\_ \* \> \. \~ \=\= \|\|` vs `\$`), so no other insertion can
|
|
56
56
|
* create or destroy a signal for a sibling. Verified by composition tests.
|
|
57
|
+
*
|
|
58
|
+
* Intentional inline math (`$x^2+y^2$`, a native Telegram construct) is
|
|
59
|
+
* PROTECTED from every sub-guard: `splitProtectedSegments` treats a compact
|
|
60
|
+
* non-currency `$…$` span like a code span (see code-segments.ts), so the
|
|
61
|
+
* dollar guard neither counts nor escapes its `$` and the emphasis/caret
|
|
62
|
+
* guards never rewrite its interior.
|
|
57
63
|
*/
|
|
58
64
|
export function guardAccidentalFormatting(markdown: string): string {
|
|
59
65
|
let out = markdown
|
|
60
|
-
// Repair Telegram-unrenderable tokens FIRST
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
66
|
+
// Repair Telegram-unrenderable tokens FIRST. This pass now strips ONLY caret
|
|
67
|
+
// pairs (`^x^` → `x`): removing a `^` inserts no trigger char for any
|
|
68
|
+
// sibling guard, so it neither creates nor destroys their signals.
|
|
69
|
+
//
|
|
70
|
+
// What this pass deliberately NO LONGER does (wire-verified 2026-08-13 by
|
|
71
|
+
// raw `sendRichMessage` probes): it used to fold `<details>` into a `**> `
|
|
72
|
+
// "expandable blockquote" and delete footnote markers `[^1]`. Both beliefs
|
|
73
|
+
// were false — `<details><summary>` and footnotes are NATIVE rich-markdown
|
|
74
|
+
// constructs (typed `details` / footnote nodes on the wire), while `**>` is
|
|
75
|
+
// MarkdownV2-only syntax the rich path renders as LITERAL `**>` text. The
|
|
76
|
+
// conversion destroyed a supported construct to emit an unsupported one, so
|
|
77
|
+
// it is deleted. `<details>`, footnotes, `<sub>`/`<sup>`/`<u>`, `<aside>`,
|
|
78
|
+
// `tg://` links and task lists all pass through every guard untouched: none
|
|
79
|
+
// of `< > [ ] : /` is a trigger char for the emphasis / heading /
|
|
80
|
+
// block-construct / inline-pair / dollar guards ('composition' tests cover
|
|
81
|
+
// this interaction).
|
|
67
82
|
out = guardUnsupportedTokens(out)
|
|
68
83
|
out = guardAccidentalEmphasis(out)
|
|
69
84
|
out = guardAccidentalHeading(out)
|
|
@@ -251,8 +251,9 @@ export function installTgPostLogger(bot: Bot): void {
|
|
|
251
251
|
* call site, `ctx.*` sugar, `lockedBot`, or `bot.api.raw`, so it closes the
|
|
252
252
|
* whole bypass class deterministically.
|
|
253
253
|
*
|
|
254
|
-
* Payload shape (verified against grammy 1.
|
|
255
|
-
* lockfile version
|
|
254
|
+
* Payload shape (verified against grammy 1.45.1 `out/core/api.js`, the pinned
|
|
255
|
+
* lockfile version — byte-identical to the 1.44.0 shape this was originally
|
|
256
|
+
* written against, re-checked on the 1.45.1 bump):
|
|
256
257
|
* - `sendRichMessage(chat_id, rich_message, ...)` → raw payload
|
|
257
258
|
* `{ chat_id, rich_message: { markdown }, ... }`
|
|
258
259
|
* - `editMessageText(chat_id, message_id, arg, ...)` → raw payload
|
|
@@ -193,10 +193,12 @@ export async function createTelegraphPage(
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
/**
|
|
196
|
-
* A blockquote line: a plain `> ` marker OR the expandable-blockquote
|
|
197
|
-
* `**> ` (
|
|
198
|
-
*
|
|
199
|
-
* `
|
|
196
|
+
* A blockquote line: a plain `> ` marker OR the LEGACY expandable-blockquote
|
|
197
|
+
* opener `**> ` (a switchroom encoding once believed to be Bot API 10.1
|
|
198
|
+
* syntax; wire probes 2026-08-13 showed the rich path renders `**>` as
|
|
199
|
+
* literal text, so `render/render.ts` no longer emits it — but legacy agent
|
|
200
|
+
* output still contains it and it must be tolerated on input here).
|
|
201
|
+
* Telegra.ph has no collapsible
|
|
200
202
|
* blockquote tag, so an expandable quote degrades to a normal `<blockquote>`;
|
|
201
203
|
* the `**` prefix must still be recognised and stripped here, else the
|
|
202
204
|
* unterminated `**` renders as literal `**>` text and the `>` continuation
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* switchroom#4641 — the boot-resume generation guard.
|
|
3
|
+
*
|
|
4
|
+
* Two mechanisms, tested for what each is actually responsible for:
|
|
5
|
+
*
|
|
6
|
+
* - the per-container-boot GENERATION TOKEN (`.boot-resume-done`) is the
|
|
7
|
+
* only thing that can say "suppress". start.sh deletes it once per
|
|
8
|
+
* container boot before forking the gateway; the gateway stamps it after
|
|
9
|
+
* completing its boot-resume block.
|
|
10
|
+
* - the `/proc` AGENT RECORD is a VETO only: it can re-enable a boot resume
|
|
11
|
+
* when the recorded agent is provably gone, never suppress one.
|
|
12
|
+
*
|
|
13
|
+
* Liveness assertions use REAL processes and the REAL `/proc`, not a mocked
|
|
14
|
+
* fs: a fixture that hand-writes both answers would pass against a probe that
|
|
15
|
+
* always says "alive". Every "alive" claim is anchored on a process this test
|
|
16
|
+
* spawned; every "dead" claim on one it killed.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately NOT tested here, because it no longer exists: any comparison of
|
|
19
|
+
* the agent's `/proc` starttime against the gateway's. The previous revision
|
|
20
|
+
* suppressed on "the agent predates me", which was correct only by accident of
|
|
21
|
+
* the docker tmux re-exec (measured margin on a live container: one clock
|
|
22
|
+
* tick) and which broke outright when a gateway crashed during its own boot.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'
|
|
26
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
27
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, unlinkSync } from 'node:fs'
|
|
28
|
+
import { tmpdir } from 'node:os'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import {
|
|
31
|
+
parseProcStat,
|
|
32
|
+
readProcIdentity,
|
|
33
|
+
readAgentProcessRecord,
|
|
34
|
+
decideGatewayOnlyRespawn,
|
|
35
|
+
detectGatewayOnlyRespawn,
|
|
36
|
+
shouldSkipBootResumeForGatewayOnlyRespawn,
|
|
37
|
+
markBootResumeComplete,
|
|
38
|
+
bootResumeDonePath,
|
|
39
|
+
readBootResumeSentinel,
|
|
40
|
+
containerBootIdentity,
|
|
41
|
+
AGENT_PROCESS_RECORD_FILE,
|
|
42
|
+
BOOT_RESUME_DONE_FILE,
|
|
43
|
+
} from '../gateway/agent-process-liveness.js'
|
|
44
|
+
|
|
45
|
+
function starttimeOf(pid: number): string {
|
|
46
|
+
const id = readProcIdentity(pid)
|
|
47
|
+
expect(id, `pid ${pid} should be live`).not.toBeNull()
|
|
48
|
+
return id!.starttime
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function spawnSleeper(): ChildProcess {
|
|
52
|
+
return spawn('sleep', ['120'], { stdio: 'ignore' })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function waitForExit(child: ChildProcess): Promise<void> {
|
|
56
|
+
if (child.exitCode != null || child.signalCode != null) return
|
|
57
|
+
await new Promise<void>((res) => child.once('exit', () => res()))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let dir: string
|
|
61
|
+
let agentProc: ChildProcess
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
dir = mkdtempSync(join(tmpdir(), 'agent-liveness-'))
|
|
65
|
+
agentProc = spawnSleeper()
|
|
66
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
afterAll(() => {
|
|
70
|
+
try { agentProc.kill('SIGKILL') } catch { /* already gone */ }
|
|
71
|
+
rmSync(dir, { recursive: true, force: true })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
// Every case states its own generation-token state explicitly.
|
|
76
|
+
try { unlinkSync(bootResumeDonePath(dir)) } catch { /* absent */ }
|
|
77
|
+
try { unlinkSync(join(dir, AGENT_PROCESS_RECORD_FILE)) } catch { /* absent */ }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
/** start.sh's outer-pass "open a new generation" step. */
|
|
81
|
+
function clearToken(): void {
|
|
82
|
+
try { unlinkSync(bootResumeDonePath(dir)) } catch { /* absent */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeRecord(rec: unknown, name = AGENT_PROCESS_RECORD_FILE): string {
|
|
86
|
+
const p = join(dir, name)
|
|
87
|
+
writeFileSync(p, typeof rec === 'string' ? rec : JSON.stringify(rec))
|
|
88
|
+
return p
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('parseProcStat', () => {
|
|
92
|
+
it('reads state + starttime past a comm containing spaces and parens', () => {
|
|
93
|
+
// A real-shaped line with a hostile comm — the naive `awk $22` split and a
|
|
94
|
+
// first-`)` split both return the wrong field here.
|
|
95
|
+
const raw =
|
|
96
|
+
'4242 (weird ) name) S 1 4242 4242 0 -1 4194304 167 0 0 0 0 0 0 0 20 0 1 0 ' +
|
|
97
|
+
'987654321 4165632 702 18446744073709551615 0 0 0 0 0 0 2 4 65536 1 0 0 17 0 0 0 0 0 0'
|
|
98
|
+
expect(parseProcStat(raw)).toEqual({
|
|
99
|
+
comm: 'weird ) name',
|
|
100
|
+
state: 'S',
|
|
101
|
+
starttime: '987654321',
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('agrees with awk on this process\'s own /proc entry', () => {
|
|
106
|
+
const raw = readFileSync(`/proc/${process.pid}/stat`, 'utf8')
|
|
107
|
+
const fields = raw.slice(raw.lastIndexOf(') ') + 2).trim().split(/\s+/)
|
|
108
|
+
expect(parseProcStat(raw)!.starttime).toBe(fields[19])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('treats a zombie as dead', () => {
|
|
112
|
+
const raw = '4242 (claude) Z 1 4242 4242 0 -1 0 0 0 0 0 0 0 0 20 0 1 0 5 ' +
|
|
113
|
+
'0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'
|
|
114
|
+
expect(parseProcStat(raw)!.state).toBe('Z')
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('the generation token decides', () => {
|
|
119
|
+
it('suppresses ONLY after a gateway stamped the token this generation', () => {
|
|
120
|
+
writeRecord({ pid: agentProc.pid, starttime: starttimeOf(agentProc.pid!) })
|
|
121
|
+
|
|
122
|
+
// Fresh container boot: start.sh cleared the token, no gateway has
|
|
123
|
+
// finished its boot resume yet. The live agent record must NOT suppress.
|
|
124
|
+
expect(detectGatewayOnlyRespawn({ stateDir: dir })).toEqual({
|
|
125
|
+
gatewayOnly: false,
|
|
126
|
+
reason: 'no-boot-resume-sentinel',
|
|
127
|
+
pid: agentProc.pid,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// …the first gateway completes its boot-resume block…
|
|
131
|
+
markBootResumeComplete(dir)
|
|
132
|
+
expect(existsSync(bootResumeDonePath(dir))).toBe(true)
|
|
133
|
+
|
|
134
|
+
// …and a respawned gateway now sees the generation already handled.
|
|
135
|
+
expect(detectGatewayOnlyRespawn({ stateDir: dir })).toEqual({
|
|
136
|
+
gatewayOnly: true,
|
|
137
|
+
reason: 'gateway-only-respawn',
|
|
138
|
+
pid: agentProc.pid,
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does NOT suppress when the gateway crashed during its own boot', () => {
|
|
143
|
+
// The #4641-motivating Bun crash-at-boot pattern: gateway #1 died before
|
|
144
|
+
// finishing the boot-resume block, so it never stamped the token — even
|
|
145
|
+
// though start.sh had already published a live agent record. Gateway #2
|
|
146
|
+
// MUST do the boot resume; the previous starttime-ordering guard
|
|
147
|
+
// suppressed here and lost the interrupted turn permanently.
|
|
148
|
+
writeRecord({ pid: agentProc.pid, starttime: starttimeOf(agentProc.pid!) })
|
|
149
|
+
const decision = detectGatewayOnlyRespawn({ stateDir: dir })
|
|
150
|
+
expect(decision.gatewayOnly).toBe(false)
|
|
151
|
+
expect(decision.reason).toBe('no-boot-resume-sentinel')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('suppresses on a token with no record yet (gateway respawn pre-exec)', () => {
|
|
155
|
+
// The gateway boots long before start.sh reaches `exec claude`. A gateway
|
|
156
|
+
// that crashes in that window must still not repeat this generation's
|
|
157
|
+
// boot resume — repeating it would spool the resume synthetic twice.
|
|
158
|
+
markBootResumeComplete(dir)
|
|
159
|
+
expect(detectGatewayOnlyRespawn({ stateDir: dir })).toEqual({
|
|
160
|
+
gatewayOnly: true,
|
|
161
|
+
reason: 'gateway-only-respawn-no-record',
|
|
162
|
+
pid: null,
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('a container restart re-opens the generation (start.sh clears the token)', () => {
|
|
167
|
+
writeRecord({ pid: agentProc.pid, starttime: starttimeOf(agentProc.pid!) })
|
|
168
|
+
markBootResumeComplete(dir)
|
|
169
|
+
expect(detectGatewayOnlyRespawn({ stateDir: dir }).gatewayOnly).toBe(true)
|
|
170
|
+
clearToken()
|
|
171
|
+
expect(detectGatewayOnlyRespawn({ stateDir: dir }).gatewayOnly).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('the /proc record can only VETO a suppression', () => {
|
|
176
|
+
it('vetoes when the recorded agent process is really dead', async () => {
|
|
177
|
+
const doomed = spawnSleeper()
|
|
178
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
179
|
+
writeRecord({ pid: doomed.pid, starttime: starttimeOf(doomed.pid!) })
|
|
180
|
+
doomed.kill('SIGKILL')
|
|
181
|
+
await waitForExit(doomed)
|
|
182
|
+
markBootResumeComplete(dir)
|
|
183
|
+
|
|
184
|
+
const decision = detectGatewayOnlyRespawn({ stateDir: dir })
|
|
185
|
+
expect(decision.gatewayOnly).toBe(false)
|
|
186
|
+
expect(decision.reason).toBe('agent-process-dead')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('vetoes a recycled PID: same pid, different starttime', () => {
|
|
190
|
+
const live = starttimeOf(agentProc.pid!)
|
|
191
|
+
writeRecord({ pid: agentProc.pid, starttime: String(BigInt(live) - 1n) })
|
|
192
|
+
markBootResumeComplete(dir)
|
|
193
|
+
expect(detectGatewayOnlyRespawn({ stateDir: dir })).toEqual({
|
|
194
|
+
gatewayOnly: false,
|
|
195
|
+
reason: 'starttime-mismatch',
|
|
196
|
+
pid: agentProc.pid,
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('cannot manufacture a suppression on its own', () => {
|
|
201
|
+
// A live, matching, older record with NO token still runs the boot resume.
|
|
202
|
+
writeRecord({ pid: agentProc.pid, starttime: starttimeOf(agentProc.pid!) })
|
|
203
|
+
expect(detectGatewayOnlyRespawn({ stateDir: dir }).gatewayOnly).toBe(false)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('fails open on a torn record even with the token present', () => {
|
|
207
|
+
markBootResumeComplete(dir)
|
|
208
|
+
writeRecord('{"pid": 12')
|
|
209
|
+
// Torn record is indistinguishable from "no record" — and with the token
|
|
210
|
+
// present that is a suppression, which is the safe answer here: the block
|
|
211
|
+
// demonstrably already ran this generation.
|
|
212
|
+
expect(detectGatewayOnlyRespawn({ stateDir: dir }).reason)
|
|
213
|
+
.toBe('gateway-only-respawn-no-record')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('honours the SWITCHROOM_GATEWAY_RESPAWN_GUARD=0 escape hatch', () => {
|
|
217
|
+
writeRecord({ pid: agentProc.pid, starttime: starttimeOf(agentProc.pid!) })
|
|
218
|
+
markBootResumeComplete(dir)
|
|
219
|
+
const lines: string[] = []
|
|
220
|
+
const prev = process.env.SWITCHROOM_GATEWAY_RESPAWN_GUARD
|
|
221
|
+
process.env.SWITCHROOM_GATEWAY_RESPAWN_GUARD = '0'
|
|
222
|
+
try {
|
|
223
|
+
expect(
|
|
224
|
+
shouldSkipBootResumeForGatewayOnlyRespawn(dir, { log: (s) => lines.push(s) }),
|
|
225
|
+
).toBe(false)
|
|
226
|
+
} finally {
|
|
227
|
+
if (prev == null) delete process.env.SWITCHROOM_GATEWAY_RESPAWN_GUARD
|
|
228
|
+
else process.env.SWITCHROOM_GATEWAY_RESPAWN_GUARD = prev
|
|
229
|
+
}
|
|
230
|
+
expect(lines.join('')).toContain('guard-disabled')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('skips the boot-resume path (and says so) for a live agent + token', () => {
|
|
234
|
+
writeRecord({ pid: agentProc.pid, starttime: starttimeOf(agentProc.pid!) })
|
|
235
|
+
markBootResumeComplete(dir)
|
|
236
|
+
const lines: string[] = []
|
|
237
|
+
expect(shouldSkipBootResumeForGatewayOnlyRespawn(dir, { log: (s) => lines.push(s) })).toBe(true)
|
|
238
|
+
const out = lines.join('')
|
|
239
|
+
expect(out).toContain('GATEWAY-ONLY respawn')
|
|
240
|
+
// The log must name the side effects the break skips (the MEDIUM finding:
|
|
241
|
+
// it skips more than the resume synthetic).
|
|
242
|
+
expect(out).toContain('bridge-dead marker')
|
|
243
|
+
expect(out).toContain('crash-redelivery')
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('markBootResumeComplete', () => {
|
|
248
|
+
it('writes the token atomically and leaves no tmp file behind', () => {
|
|
249
|
+
markBootResumeComplete(dir)
|
|
250
|
+
const p = bootResumeDonePath(dir)
|
|
251
|
+
expect(p.endsWith(BOOT_RESUME_DONE_FILE)).toBe(true)
|
|
252
|
+
expect(JSON.parse(readFileSync(p, 'utf8'))).toMatchObject({ pid: process.pid })
|
|
253
|
+
expect(existsSync(`${p}.tmp.${process.pid}`)).toBe(false)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('never throws when the state dir is unwritable — it logs and continues', () => {
|
|
257
|
+
const lines: string[] = []
|
|
258
|
+
expect(() =>
|
|
259
|
+
markBootResumeComplete(join(dir, 'no', 'such', 'dir'), { log: (s) => lines.push(s) }),
|
|
260
|
+
).not.toThrow()
|
|
261
|
+
expect(lines.join('')).toContain(BOOT_RESUME_DONE_FILE)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe('decideGatewayOnlyRespawn (pure)', () => {
|
|
266
|
+
const record = { pid: 42, starttime: '1000' }
|
|
267
|
+
const live = { starttime: '1000', comm: 'claude', state: 'S' }
|
|
268
|
+
|
|
269
|
+
it('rejects a comm mismatch when the record carries one', () => {
|
|
270
|
+
expect(
|
|
271
|
+
decideGatewayOnlyRespawn({
|
|
272
|
+
sentinelPresent: true,
|
|
273
|
+
record: { ...record, comm: 'claude' },
|
|
274
|
+
live: { ...live, comm: 'imposter' },
|
|
275
|
+
}),
|
|
276
|
+
).toEqual({ gatewayOnly: false, reason: 'comm-mismatch', pid: 42 })
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('never suppresses without the generation token, whatever /proc says', () => {
|
|
280
|
+
for (const l of [live, null]) {
|
|
281
|
+
expect(decideGatewayOnlyRespawn({ sentinelPresent: false, record, live: l }).gatewayOnly)
|
|
282
|
+
.toBe(false)
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
describe('readAgentProcessRecord', () => {
|
|
288
|
+
it('accepts the exact shape start.sh writes', () => {
|
|
289
|
+
const p = writeRecord({ pid: 7, starttime: '213153204', boot_at: 1786572493000 })
|
|
290
|
+
expect(readAgentProcessRecord(p)).toEqual({
|
|
291
|
+
pid: 7,
|
|
292
|
+
starttime: '213153204',
|
|
293
|
+
boot_at: 1786572493000,
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('rejects a nonsense pid', () => {
|
|
298
|
+
expect(readAgentProcessRecord(writeRecord({ pid: 0, starttime: '5' }))).toBeNull()
|
|
299
|
+
expect(readAgentProcessRecord(writeRecord({ pid: -1, starttime: '5' }))).toBeNull()
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// #4648 LOW-2: the token carries the container-boot identity
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
describe('generation-token boot identity', () => {
|
|
308
|
+
it('stamps the live container-boot identity (PID 1 starttime) into the token', () => {
|
|
309
|
+
markBootResumeComplete(dir)
|
|
310
|
+
const body = JSON.parse(readFileSync(bootResumeDonePath(dir), 'utf8')) as { boot?: string }
|
|
311
|
+
expect(body.boot).toBe(containerBootIdentity()!)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('treats a token from a DIFFERENT container boot as stale, not as a suppressor', () => {
|
|
315
|
+
// The failure this closes: start.sh's `rm -f … 2>/dev/null || true` swallows
|
|
316
|
+
// a per-file unlink failure, so a token can outlive its generation and
|
|
317
|
+
// suppress EVERY later resume — permanently and silently, since
|
|
318
|
+
// `gateway-only-respawn-no-record` needs no corroborating evidence.
|
|
319
|
+
writeFileSync(
|
|
320
|
+
bootResumeDonePath(dir),
|
|
321
|
+
JSON.stringify({ pid: 4242, at: Date.now(), boot: '1' }) + '\n',
|
|
322
|
+
)
|
|
323
|
+
const decision = detectGatewayOnlyRespawn({ stateDir: dir })
|
|
324
|
+
expect(decision.gatewayOnly).toBe(false)
|
|
325
|
+
expect(decision.reason).toBe('stale-boot-token')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('still suppresses for a token stamped in THIS boot, with no record present', () => {
|
|
329
|
+
// The `gateway-only-respawn-no-record` path must survive the hardening:
|
|
330
|
+
// the gateway boots long before start.sh `exec`s claude.
|
|
331
|
+
markBootResumeComplete(dir)
|
|
332
|
+
expect(existsSync(join(dir, AGENT_PROCESS_RECORD_FILE))).toBe(false)
|
|
333
|
+
const decision = detectGatewayOnlyRespawn({ stateDir: dir })
|
|
334
|
+
expect(decision.gatewayOnly).toBe(true)
|
|
335
|
+
expect(decision.reason).toBe('gateway-only-respawn-no-record')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('keeps the pre-existing behaviour when the token carries NO identity', () => {
|
|
339
|
+
// One-directional by design: only a positive MISMATCH is evidence. A
|
|
340
|
+
// legacy/identity-less token must not become a fail-open path, or the
|
|
341
|
+
// hardening would itself re-open #4641.
|
|
342
|
+
writeFileSync(bootResumeDonePath(dir), JSON.stringify({ pid: 4242, at: Date.now() }) + '\n')
|
|
343
|
+
expect(readBootResumeSentinel(bootResumeDonePath(dir))).toEqual({ present: true, stale: false })
|
|
344
|
+
expect(detectGatewayOnlyRespawn({ stateDir: dir }).gatewayOnly).toBe(true)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('keeps the pre-existing behaviour when the token body is unparseable', () => {
|
|
348
|
+
writeFileSync(bootResumeDonePath(dir), 'not json at all')
|
|
349
|
+
expect(readBootResumeSentinel(bootResumeDonePath(dir))).toEqual({ present: true, stale: false })
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('keeps the pre-existing behaviour when /proc/1 is unreadable', () => {
|
|
353
|
+
// No live identity to compare against → no evidence → not stale.
|
|
354
|
+
writeFileSync(
|
|
355
|
+
bootResumeDonePath(dir),
|
|
356
|
+
JSON.stringify({ pid: 4242, at: Date.now(), boot: '1' }) + '\n',
|
|
357
|
+
)
|
|
358
|
+
const emptyProc = mkdtempSync(join(tmpdir(), 'noproc-'))
|
|
359
|
+
try {
|
|
360
|
+
expect(containerBootIdentity(emptyProc)).toBeNull()
|
|
361
|
+
expect(readBootResumeSentinel(bootResumeDonePath(dir), { procRoot: emptyProc }))
|
|
362
|
+
.toEqual({ present: true, stale: false })
|
|
363
|
+
} finally {
|
|
364
|
+
rmSync(emptyProc, { recursive: true, force: true })
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('reports absent when there is no token at all', () => {
|
|
369
|
+
clearToken()
|
|
370
|
+
expect(readBootResumeSentinel(bootResumeDonePath(dir))).toEqual({ present: false, stale: false })
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// #4648 LOW-1: no env var may redirect the token/record paths
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
describe('path overrides are test-injection only (fail-open invariant)', () => {
|
|
379
|
+
it('ignores SWITCHROOM_BOOT_RESUME_DONE_FILE / SWITCHROOM_AGENT_PROCESS_FILE', () => {
|
|
380
|
+
// If these were honoured, start.sh (which hard-codes
|
|
381
|
+
// "$TELEGRAM_STATE_DIR/.boot-resume-done") would clear one path while the
|
|
382
|
+
// gateway read another: the token would never be cleared and EVERY boot
|
|
383
|
+
// would suppress its resume forever — the module's one fail-CLOSED path.
|
|
384
|
+
const decoy = mkdtempSync(join(tmpdir(), 'decoy-'))
|
|
385
|
+
const prevToken = process.env.SWITCHROOM_BOOT_RESUME_DONE_FILE
|
|
386
|
+
const prevRecord = process.env.SWITCHROOM_AGENT_PROCESS_FILE
|
|
387
|
+
try {
|
|
388
|
+
process.env.SWITCHROOM_BOOT_RESUME_DONE_FILE = join(decoy, 'token')
|
|
389
|
+
process.env.SWITCHROOM_AGENT_PROCESS_FILE = join(decoy, 'record')
|
|
390
|
+
clearToken()
|
|
391
|
+
// A token written into the decoy path must NOT be seen…
|
|
392
|
+
writeFileSync(join(decoy, 'token'), JSON.stringify({ pid: 1, at: Date.now() }) + '\n')
|
|
393
|
+
expect(shouldSkipBootResumeForGatewayOnlyRespawn(dir, { log: () => {} })).toBe(false)
|
|
394
|
+
// …and the stamp must land in stateDir, not the decoy.
|
|
395
|
+
markBootResumeComplete(dir)
|
|
396
|
+
expect(existsSync(bootResumeDonePath(dir))).toBe(true)
|
|
397
|
+
expect(shouldSkipBootResumeForGatewayOnlyRespawn(dir, { log: () => {} })).toBe(true)
|
|
398
|
+
} finally {
|
|
399
|
+
if (prevToken == null) delete process.env.SWITCHROOM_BOOT_RESUME_DONE_FILE
|
|
400
|
+
else process.env.SWITCHROOM_BOOT_RESUME_DONE_FILE = prevToken
|
|
401
|
+
if (prevRecord == null) delete process.env.SWITCHROOM_AGENT_PROCESS_FILE
|
|
402
|
+
else process.env.SWITCHROOM_AGENT_PROCESS_FILE = prevRecord
|
|
403
|
+
rmSync(decoy, { recursive: true, force: true })
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
})
|