switchroom 0.21.12 → 0.21.13
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/dist/agent-scheduler/index.js +23 -0
- package/dist/auth-broker/index.js +24 -1
- package/dist/cli/notion-write-pretool.mjs +23 -0
- package/dist/cli/switchroom.js +3221 -1755
- package/dist/host-control/main.js +25 -2
- package/dist/vault/approvals/kernel-server.js +24 -1
- package/dist/vault/broker/server.js +24 -1
- package/package.json +1 -1
- package/skills/switchroom-architecture/SKILL.md +11 -7
- package/skills/switchroom-architecture/cascade.md +29 -10
- package/skills/switchroom-architecture/sub-agents.md +27 -26
- package/skills/switchroom-architecture/telegram.md +44 -17
- package/skills/switchroom-status/SKILL.md +17 -18
- package/skills/switchroom-status/scripts/status.sh +8 -17
- package/skills/telegram-test-harness/SKILL.md +6 -5
- package/telegram-plugin/dist/gateway/gateway.js +133 -5
- package/telegram-plugin/gateway/gateway.ts +2 -0
- package/telegram-plugin/shared/utf8-sanitize.ts +269 -0
- package/telegram-plugin/tests/utf8-sanitize-wire.test.ts +588 -0
|
@@ -22,18 +22,16 @@ RAW=$(switchroom agent list --json 2>/dev/null) || {
|
|
|
22
22
|
exit 1
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
if [ -z "$RAW" ] || [ "$RAW" = "[]
|
|
25
|
+
if [ -z "$RAW" ] || [ "$RAW" = '{"agents":[]}' ]; then
|
|
26
26
|
echo "No agents configured."
|
|
27
27
|
exit 0
|
|
28
28
|
fi
|
|
29
29
|
|
|
30
|
-
TOTAL=$(echo "$RAW" | python3 -c "import sys,json; agents=json.load(sys.stdin); print(len(agents))" 2>/dev/null || echo "?")
|
|
31
|
-
RUNNING=0
|
|
32
|
-
|
|
33
30
|
echo "$RAW" | python3 -c "
|
|
34
|
-
import sys, json
|
|
31
|
+
import sys, json
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
payload = json.load(sys.stdin)
|
|
34
|
+
agents = payload.get('agents', [])
|
|
37
35
|
running = 0
|
|
38
36
|
|
|
39
37
|
for a in agents:
|
|
@@ -41,11 +39,9 @@ for a in agents:
|
|
|
41
39
|
status = a.get('status', 'unknown')
|
|
42
40
|
model = a.get('model', 'unknown')
|
|
43
41
|
topic = a.get('topic_name', '')
|
|
44
|
-
coll = a.get('memory', {}).get('collection', '')
|
|
45
42
|
uptime = a.get('uptime', '')
|
|
46
|
-
pid = a.get('pid', '')
|
|
47
43
|
|
|
48
|
-
status_icon = '✓' if status == '
|
|
44
|
+
status_icon = '✓' if status == 'active' else '✗' if status in ('inactive', 'exited', 'dead') else '?'
|
|
49
45
|
|
|
50
46
|
line = f'{status_icon} {name}'
|
|
51
47
|
if topic:
|
|
@@ -54,16 +50,11 @@ for a in agents:
|
|
|
54
50
|
if uptime:
|
|
55
51
|
line += f' ({uptime})'
|
|
56
52
|
print(line)
|
|
57
|
-
print(f' model: {model}'
|
|
58
|
-
if coll:
|
|
59
|
-
print(f' collection: {coll}', end='')
|
|
60
|
-
if pid:
|
|
61
|
-
print(f' pid: {pid}', end='')
|
|
62
|
-
print()
|
|
53
|
+
print(f' model: {model}')
|
|
63
54
|
print()
|
|
64
55
|
|
|
65
|
-
if status == '
|
|
56
|
+
if status == 'active':
|
|
66
57
|
running += 1
|
|
67
58
|
|
|
68
59
|
print(f'{running} of {len(agents)} agents running.')
|
|
69
|
-
"
|
|
60
|
+
"
|
|
@@ -23,8 +23,12 @@ at `telegram-plugin/tests/`. Use it to lock in the Bot API call
|
|
|
23
23
|
sequences switchroom emits — what the user actually sees in chat — so
|
|
24
24
|
regressions fail a test instead of going silent in production.
|
|
25
25
|
|
|
26
|
-
This skill is
|
|
27
|
-
|
|
26
|
+
This skill is the reference for that harness — there is no separate
|
|
27
|
+
`HARNESS.md` guide file (it existed briefly, was deleted in the pinned
|
|
28
|
+
progress-card removal (#1126), and was never recreated). Read the fake
|
|
29
|
+
implementations directly for the authoritative contract:
|
|
30
|
+
`telegram-plugin/tests/fake-bot-api.ts`, `update-factory.ts`,
|
|
31
|
+
`bot-api.harness.ts`.
|
|
28
32
|
|
|
29
33
|
## When to use this harness
|
|
30
34
|
|
|
@@ -147,8 +151,6 @@ Example pairs:
|
|
|
147
151
|
|
|
148
152
|
- `slot-banner.test.ts` (pure decision) +
|
|
149
153
|
`slot-banner-driver.e2e.test.ts` (Bot API dispatch)
|
|
150
|
-
- `auto-fallback.test.ts` (pure plan) +
|
|
151
|
-
`auto-fallback-dispatcher.e2e.test.ts` (notification dispatch)
|
|
152
154
|
|
|
153
155
|
## Test-design checklist
|
|
154
156
|
|
|
@@ -187,7 +189,6 @@ Before writing the test, ask:
|
|
|
187
189
|
|
|
188
190
|
## See also
|
|
189
191
|
|
|
190
|
-
- `telegram-plugin/tests/HARNESS.md` — full guide
|
|
191
192
|
- `telegram-plugin/tests/fake-bot-api.test.ts` — meta-test of the fake;
|
|
192
193
|
read first when adding new fake-bot capabilities
|
|
193
194
|
- `telegram-plugin/tests/streaming-e2e.test.ts` — worked example of a
|
|
@@ -21648,7 +21648,7 @@ var init_observation_scopes = __esm(() => {
|
|
|
21648
21648
|
});
|
|
21649
21649
|
|
|
21650
21650
|
// ../src/config/schema.ts
|
|
21651
|
-
var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
|
|
21651
|
+
var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, ObservationScopesSchema, ObservationScopeStrategySchema, AntiConfabulationDirectiveSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, BuzzChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleServiceTokenSchema, GoogleWorkspaceConfigSchema, LiteLLMConfigSchema, HindsightPerOpLlmSchema, HindsightConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, MicrosoftAccountEmailSchema, MicrosoftToolTokenSchema, MicrosoftAccountBindingSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, releaseBlockFields, ReleaseBlock, RootReleaseBlock, NetworkIsolationSchema, servesField, knowsField, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, ScratchConfigSchema, DiskConfigSchema, HostControlConfigSchema, WebServiceConfigSchema, FleetHealthConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, UserSchema, ConfigRepoConfigSchema, SwitchroomConfigSchema;
|
|
21652
21652
|
var init_schema = __esm(() => {
|
|
21653
21653
|
init_zod();
|
|
21654
21654
|
init_observation_scopes();
|
|
@@ -22521,6 +22521,27 @@ var init_schema = __esm(() => {
|
|
|
22521
22521
|
notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 \u2014 operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind \u2014 tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
|
|
22522
22522
|
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
22523
22523
|
});
|
|
22524
|
+
ScratchConfigSchema = exports_external.object({
|
|
22525
|
+
enabled: exports_external.boolean().default(true).describe("Whether agents get a scratch mount at /scratch with their package " + "caches redirected there. Default: true \u2014 but the feature only " + "engages when `volume` actually exists on the host, so a single-disk " + "machine is unaffected. Set false to opt out even where it does."),
|
|
22526
|
+
volume: exports_external.string().refine((v) => v.startsWith("/"), {
|
|
22527
|
+
message: "scratch.volume must be an absolute host path"
|
|
22528
|
+
}).default("/mnt/bulkdata").describe("Absolute host path of the bulk device's mountpoint. MUST already " + "exist \u2014 its presence is the single probe that turns the feature on, " + "so a typo degrades to the pre-existing behaviour rather than " + "quietly relocating caches somewhere that is still the root disk. " + "Default: /mnt/bulkdata."),
|
|
22529
|
+
subdir: exports_external.string().refine((v) => !v.startsWith("/") && !v.split("/").includes(".."), {
|
|
22530
|
+
message: "scratch.subdir must be a relative path without '..' segments"
|
|
22531
|
+
}).default("switchroom/scratch").describe("Relative path under `volume` holding the per-agent scratch " + "directories (`<volume>/<subdir>/<agent>`). Default: " + "switchroom/scratch.")
|
|
22532
|
+
});
|
|
22533
|
+
DiskConfigSchema = exports_external.object({
|
|
22534
|
+
warn_pct: exports_external.number().int().min(1).max(99).default(80).describe("Used-percentage at or above which `switchroom doctor` WARNs about " + "the filesystem holding the agents directory. Default 80 \u2014 the " + "reference fleet was at 85% when the condition was found by hand."),
|
|
22535
|
+
fail_pct: exports_external.number().int().min(2).max(100).default(90).describe("Used-percentage at or above which `switchroom doctor` FAILs. Must " + "be greater than `warn_pct`. Default 90."),
|
|
22536
|
+
reap_report: exports_external.object({
|
|
22537
|
+
enabled: exports_external.boolean().default(true).describe("Whether doctor checks that the report-only worktree sweep " + "(`switchroom worktree reap-report --append <file>`) is actually " + "running. Set false on a host that deliberately does not run it."),
|
|
22538
|
+
log: exports_external.string().min(1).default("/var/log/switchroom/reap-report.jsonl").describe("Path of the JSONL evidence log the scheduled `worktree " + "reap-report --append` writes. Doctor reads the newest record's " + "`generatedAt` \u2014 it detects the sweep by its OUTPUT, so it is " + "agnostic about whether an operator crontab, /etc/cron.d, or a " + "systemd timer drives it. Default " + "/var/log/switchroom/reap-report.jsonl (the path documented in " + "docs/operators/worktree-gc.md)."),
|
|
22539
|
+
max_age_hours: exports_external.number().int().min(1).max(720).default(48).describe("How old the newest record in `log` may be before doctor WARNs " + "that the sweep has stopped running. Default 48 \u2014 twice the " + "documented daily cadence, so a single missed run is not noise.")
|
|
22540
|
+
}).default({}).describe("Liveness check for the report-only worktree sweep. REPORT-ONLY by " + "construction: doctor reads an evidence log and never invokes any " + "reclaim path.")
|
|
22541
|
+
}).refine((v) => v.fail_pct > v.warn_pct, {
|
|
22542
|
+
message: "disk.fail_pct must be greater than disk.warn_pct",
|
|
22543
|
+
path: ["fail_pct"]
|
|
22544
|
+
});
|
|
22524
22545
|
HostControlConfigSchema = exports_external.object({
|
|
22525
22546
|
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip \u2014 the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` \u2014 " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C \u00a75.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3)."),
|
|
22526
22547
|
auto_release_check: AutoReleaseCheckSchema.default({}).describe("Pull-based release-triggered fleet restart (#1743). hostd polls " + "the remote release tag on a fixed interval and applies + " + "restarts the fleet (graceful) when a new release is detected. " + "Opt-in: default enabled=false.")
|
|
@@ -22595,6 +22616,8 @@ var init_schema = __esm(() => {
|
|
|
22595
22616
|
microsoft_workspace: MicrosoftWorkspaceConfigSchema.describe("RFC #1873 (Microsoft 365 integration). Top-level Microsoft Workspace " + "configuration \u2014 OAuth client credentials (Entra app), authority " + "endpoint (defaults to /common for personal MSA + work), and the " + "org_mode opt-in for Teams/SharePoint surfaces. Block is optional; " + "when omitted the broker does not register the Microsoft provider."),
|
|
22596
22617
|
notion_workspace: NotionWorkspaceConfigSchema.describe("RFC reference/rfcs/notion-integration.md. Top-level Notion integration " + "config \u2014 vault key for the integration token, friendly-name \u2192 " + "database UUID map, optional MCP-package version pin, and optional " + "global rate-limit override (default 3 rps, Notion's documented " + "public-API limit). Block is optional; when omitted no agent gets a " + "Notion MCP entry regardless of per-agent config."),
|
|
22597
22618
|
quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
|
|
22619
|
+
scratch: ScratchConfigSchema.optional().describe("Per-agent scratch volume. Relocates every agent's build/package " + "caches (uv, npm, bun, playwright, puppeteer, pip user-site) off the " + "root disk onto a bulk device, bind-mounted at /scratch inside each " + "container. Framework-injected for EVERY agent \u2014 not routed through " + "the admin-only `bind_mounts:` escalation, because the biggest cache " + "consumers are ordinary non-admin agents. Omit the block to accept " + "defaults; the feature is a no-op unless `scratch.volume` exists on " + "the host."),
|
|
22620
|
+
disk: DiskConfigSchema.default({}).describe("Root-disk headroom thresholds for `switchroom doctor`. Measured " + "against the filesystem holding the agents directory (and the scratch " + "volume when that feature is engaged), not against `/`. Omit the block " + "to accept the defaults (WARN at 80% used, FAIL at 90%)."),
|
|
22598
22621
|
host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (reference/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
|
|
22599
22622
|
hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Scopes the opt-in flag and rate cap for the " + "`config_propose_edit` verb (disabled by default)."),
|
|
22600
22623
|
fleet_health: FleetHealthConfigSchema.default({}).describe("Fleet Health \u2014 job-spec-anchored, operator-facing issue tracker (RFC " + "fleet-health.md, serves fleet-stays-healthy). Assigns the owner agent " + "that runs the nightly model-free sensor + weekly deep-dive. Default " + "unset owner_agent \u2192 inert; the admin page renders an empty state."),
|
|
@@ -67956,6 +67979,110 @@ function resolveSendContext(payload) {
|
|
|
67956
67979
|
};
|
|
67957
67980
|
}
|
|
67958
67981
|
|
|
67982
|
+
// shared/utf8-sanitize.ts
|
|
67983
|
+
var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
67984
|
+
function hasLoneSurrogate(s) {
|
|
67985
|
+
LONE_SURROGATE.lastIndex = 0;
|
|
67986
|
+
return LONE_SURROGATE.test(s);
|
|
67987
|
+
}
|
|
67988
|
+
function countLoneSurrogates(s) {
|
|
67989
|
+
return (s.match(LONE_SURROGATE) ?? []).length;
|
|
67990
|
+
}
|
|
67991
|
+
function sanitizeLoneSurrogates(s) {
|
|
67992
|
+
if (!hasLoneSurrogate(s))
|
|
67993
|
+
return s;
|
|
67994
|
+
return s.replace(LONE_SURROGATE, "\ufffd");
|
|
67995
|
+
}
|
|
67996
|
+
function isWalkable(v) {
|
|
67997
|
+
if (Array.isArray(v))
|
|
67998
|
+
return true;
|
|
67999
|
+
if (v === null || typeof v !== "object")
|
|
68000
|
+
return false;
|
|
68001
|
+
const proto = Object.getPrototypeOf(v);
|
|
68002
|
+
return proto === Object.prototype || proto === null;
|
|
68003
|
+
}
|
|
68004
|
+
var MAX_DEPTH3 = 16;
|
|
68005
|
+
function defineOwn(out, k, v) {
|
|
68006
|
+
Object.defineProperty(out, k, {
|
|
68007
|
+
value: v,
|
|
68008
|
+
writable: true,
|
|
68009
|
+
enumerable: true,
|
|
68010
|
+
configurable: true
|
|
68011
|
+
});
|
|
68012
|
+
}
|
|
68013
|
+
function sanitizePayloadStrings(value, depth = 0, stats) {
|
|
68014
|
+
if (typeof value === "string") {
|
|
68015
|
+
const next = sanitizeLoneSurrogates(value);
|
|
68016
|
+
if (stats && next !== value)
|
|
68017
|
+
stats.repaired += countLoneSurrogates(value);
|
|
68018
|
+
return next;
|
|
68019
|
+
}
|
|
68020
|
+
if (depth >= MAX_DEPTH3 || !isWalkable(value))
|
|
68021
|
+
return value;
|
|
68022
|
+
if (Array.isArray(value)) {
|
|
68023
|
+
let out2;
|
|
68024
|
+
for (let i = 0;i < value.length; i++) {
|
|
68025
|
+
const item = value[i];
|
|
68026
|
+
const next = sanitizePayloadStrings(item, depth + 1, stats);
|
|
68027
|
+
if (out2 === undefined && next !== item)
|
|
68028
|
+
out2 = value.slice(0, i);
|
|
68029
|
+
if (out2 !== undefined)
|
|
68030
|
+
out2.push(next);
|
|
68031
|
+
}
|
|
68032
|
+
return out2 ?? value;
|
|
68033
|
+
}
|
|
68034
|
+
const hasOwn = Object.prototype.hasOwnProperty;
|
|
68035
|
+
let out;
|
|
68036
|
+
for (const k in value) {
|
|
68037
|
+
if (!hasOwn.call(value, k))
|
|
68038
|
+
continue;
|
|
68039
|
+
const v = value[k];
|
|
68040
|
+
const next = sanitizePayloadStrings(v, depth + 1, stats);
|
|
68041
|
+
if (out === undefined && next !== v) {
|
|
68042
|
+
out = {};
|
|
68043
|
+
for (const prev in value) {
|
|
68044
|
+
if (prev === k)
|
|
68045
|
+
break;
|
|
68046
|
+
if (!hasOwn.call(value, prev))
|
|
68047
|
+
continue;
|
|
68048
|
+
defineOwn(out, prev, value[prev]);
|
|
68049
|
+
}
|
|
68050
|
+
}
|
|
68051
|
+
if (out !== undefined)
|
|
68052
|
+
defineOwn(out, k, next);
|
|
68053
|
+
}
|
|
68054
|
+
return out ?? value;
|
|
68055
|
+
}
|
|
68056
|
+
var LOG_THROTTLE_MS = 60000;
|
|
68057
|
+
function installUtf8Sanitizer(bot, now = Date.now) {
|
|
68058
|
+
const lastLoggedAtByMethod = new Map;
|
|
68059
|
+
const shouldLogRepair = (method, at) => {
|
|
68060
|
+
const last = lastLoggedAtByMethod.get(method);
|
|
68061
|
+
if (last !== undefined && at - last < LOG_THROTTLE_MS)
|
|
68062
|
+
return false;
|
|
68063
|
+
lastLoggedAtByMethod.set(method, at);
|
|
68064
|
+
return true;
|
|
68065
|
+
};
|
|
68066
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
68067
|
+
let clean = payload;
|
|
68068
|
+
const stats = { repaired: 0 };
|
|
68069
|
+
try {
|
|
68070
|
+
clean = sanitizePayloadStrings(payload, 0, stats);
|
|
68071
|
+
} catch (err) {
|
|
68072
|
+
clean = payload;
|
|
68073
|
+
if (shouldLogRepair(`${method}\x00sanitize-failed`, now())) {
|
|
68074
|
+
process.stderr.write(`telegram gateway: utf8-sanitize FAILED OPEN method=${method} \u2014 ` + `payload sent unrepaired; if it carries a lone surrogate Telegram ` + `will reject it (400 "strings must be encoded in UTF-8"): ` + `${err instanceof Error ? err.message : String(err)}
|
|
68075
|
+
`);
|
|
68076
|
+
}
|
|
68077
|
+
}
|
|
68078
|
+
if (clean !== payload && shouldLogRepair(method, now())) {
|
|
68079
|
+
process.stderr.write(`telegram gateway: utf8-sanitize repaired ${stats.repaired} lone surrogate(s) ` + `method=${method} \u2014 body would have been rejected by Telegram ` + `(400 "strings must be encoded in UTF-8")
|
|
68080
|
+
`);
|
|
68081
|
+
}
|
|
68082
|
+
return prev(method, clean, signal);
|
|
68083
|
+
});
|
|
68084
|
+
}
|
|
68085
|
+
|
|
67959
68086
|
// shared/sent-text-capture.ts
|
|
67960
68087
|
var SENT_TEXT = Symbol.for("switchroom.telegram.sentText");
|
|
67961
68088
|
var MAX_BLOCK_DEPTH = 8;
|
|
@@ -105739,10 +105866,10 @@ function startOutboxSweep(deps) {
|
|
|
105739
105866
|
}
|
|
105740
105867
|
|
|
105741
105868
|
// ../src/build-info.ts
|
|
105742
|
-
var VERSION2 = "0.21.
|
|
105743
|
-
var COMMIT_SHA = "
|
|
105744
|
-
var COMMIT_DATE = "2026-08-
|
|
105745
|
-
var LATEST_PR =
|
|
105869
|
+
var VERSION2 = "0.21.13";
|
|
105870
|
+
var COMMIT_SHA = "4a70ee58";
|
|
105871
|
+
var COMMIT_DATE = "2026-08-15T09:11:57Z";
|
|
105872
|
+
var LATEST_PR = 4734;
|
|
105746
105873
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
105747
105874
|
|
|
105748
105875
|
// gateway/boot-version.ts
|
|
@@ -119159,6 +119286,7 @@ async function initGatewayBot() {
|
|
|
119159
119286
|
process.exit(1);
|
|
119160
119287
|
}
|
|
119161
119288
|
bot = new import_grammy17.Bot(TOKEN);
|
|
119289
|
+
installUtf8Sanitizer(bot);
|
|
119162
119290
|
installTgPostLogger(bot);
|
|
119163
119291
|
installRichMarkdownGuard(bot);
|
|
119164
119292
|
installSentTextCapture(bot);
|
|
@@ -303,6 +303,7 @@ import { installEditFloodFuse, editFloodFuseConfigFromEnv } from '../edit-flood-
|
|
|
303
303
|
import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
|
|
304
304
|
import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
|
|
305
305
|
import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags, withTgSendContext, installSystemMessageObserver } from '../shared/bot-runtime.js'
|
|
306
|
+
import { installUtf8Sanitizer } from '../shared/utf8-sanitize.js'
|
|
306
307
|
import { installSentTextCapture } from '../shared/sent-text-capture.js'
|
|
307
308
|
import {
|
|
308
309
|
floodStatePath,
|
|
@@ -22873,6 +22874,7 @@ async function initGatewayBot(): Promise<void> {
|
|
|
22873
22874
|
}
|
|
22874
22875
|
|
|
22875
22876
|
bot = new Bot(TOKEN)
|
|
22877
|
+
installUtf8Sanitizer(bot) // #4728: installed FIRST so it composes INNERMOST — the last thing to touch the payload before serialisation; see installUtf8Sanitizer docblock
|
|
22876
22878
|
installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
|
|
22877
22879
|
// #4576 follow-up: FALLBACK card body. The observer takes the stored body off
|
|
22878
22880
|
// the RESPONSE (`rich_message` → `text`/`caption`); this stamps the REQUEST body
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-level UTF-8 sanitiser (#4728).
|
|
3
|
+
*
|
|
4
|
+
* ── The failure this exists to stop ───────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* A JavaScript string is UTF-16, and UTF-16 permits a LONE SURROGATE — a high
|
|
7
|
+
* (`\uD800`-`\uDBFF`) or low (`\uDC00`-`\uDFFF`) code unit with no partner.
|
|
8
|
+
* A lone surrogate has NO valid UTF-8 encoding. `JSON.stringify` does not
|
|
9
|
+
* throw on one (well-formed `JSON.stringify`, ES2019): it emits the `\udXXX`
|
|
10
|
+
* escape, so grammy happily POSTs a body Telegram's decoder then rejects with
|
|
11
|
+
*
|
|
12
|
+
* 400 Bad Request: strings must be encoded in UTF-8
|
|
13
|
+
*
|
|
14
|
+
* Observed in production on 2026-07-31 (agent `gymbro`, twice): a
|
|
15
|
+
* `permission_request` approval card 400'd on `sendRichMessage` with exactly
|
|
16
|
+
* that description and the operator never saw the card. An approval card is
|
|
17
|
+
* the human safety boundary for a gated tool call — a dropped card means a
|
|
18
|
+
* gated action silently never happens.
|
|
19
|
+
*
|
|
20
|
+
* ── Why HERE and not in the card formatter ────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* The plugin already repairs a *trailing* high surrogate at each of its
|
|
23
|
+
* truncation sites (`card-layout.ts:384`, `format.ts:1551`,
|
|
24
|
+
* `tool-activity-summary.ts:488`, `reply-quote.ts:78`). Every one of those is
|
|
25
|
+
* a per-cut patch that (a) only covers the cut it guards and (b) only covers
|
|
26
|
+
* the HIGH half — an orphaned LOW surrogate, or a lone surrogate that entered
|
|
27
|
+
* the body from upstream data (a tool `input_preview`, a file path, a
|
|
28
|
+
* model-emitted token) rather than from a cut, sails straight through all of
|
|
29
|
+
* them onto the wire. So the repair belongs at the ONE place every outbound
|
|
30
|
+
* call must transit: the grammy API-transformer layer, which no `ctx.*`
|
|
31
|
+
* helper and no raw `bot.api.*` call can bypass
|
|
32
|
+
* (see `shared/bot-runtime.ts` header).
|
|
33
|
+
*
|
|
34
|
+
* The sanitiser walks the WHOLE payload, not a named field list, because a
|
|
35
|
+
* lone surrogate anywhere in the JSON body fails the whole request — inline
|
|
36
|
+
* keyboard button labels (`reply_markup`, which every approval card carries),
|
|
37
|
+
* captions, `rich_message.markdown` and plain `text` alike.
|
|
38
|
+
*
|
|
39
|
+
* Substitution is U+FFFD REPLACEMENT CHARACTER rather than deletion: it is
|
|
40
|
+
* the Unicode-standard substitution, and it is length-preserving in UTF-16
|
|
41
|
+
* code units, so a body that was sized against a char budget upstream cannot
|
|
42
|
+
* be pushed over that budget by the repair.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type { Bot } from 'grammy'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Matches a code unit that is a surrogate with no partner:
|
|
49
|
+
* a high surrogate not followed by a low one, or a low surrogate not
|
|
50
|
+
* preceded by a high one. A well-formed pair matches neither alternative.
|
|
51
|
+
*/
|
|
52
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g
|
|
53
|
+
|
|
54
|
+
/** True when `s` contains at least one unpaired surrogate. */
|
|
55
|
+
export function hasLoneSurrogate(s: string): boolean {
|
|
56
|
+
LONE_SURROGATE.lastIndex = 0
|
|
57
|
+
return LONE_SURROGATE.test(s)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How many unpaired surrogate code units `s` carries. Safe to call on the
|
|
62
|
+
* shared `/g` regex: `String.prototype.match` with a global pattern resets
|
|
63
|
+
* `lastIndex` to 0 itself before it collects, so this cannot leak state into
|
|
64
|
+
* `hasLoneSurrogate`.
|
|
65
|
+
*/
|
|
66
|
+
function countLoneSurrogates(s: string): number {
|
|
67
|
+
return (s.match(LONE_SURROGATE) ?? []).length
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Replace every unpaired surrogate in `s` with U+FFFD. Returns the SAME
|
|
72
|
+
* string instance when there is nothing to repair, so callers can use
|
|
73
|
+
* identity to detect a no-op. Idempotent: U+FFFD is not a surrogate.
|
|
74
|
+
*/
|
|
75
|
+
export function sanitizeLoneSurrogates(s: string): string {
|
|
76
|
+
if (!hasLoneSurrogate(s)) return s
|
|
77
|
+
return s.replace(LONE_SURROGATE, '�')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* True for a value we may safely recurse into and rebuild: a plain object or
|
|
82
|
+
* an array. Anything else (an `InputFile`, a `Buffer`, a stream, a `Date`,
|
|
83
|
+
* a class instance) is returned untouched — rebuilding it would destroy its
|
|
84
|
+
* prototype and grammy's multipart layer depends on those identities.
|
|
85
|
+
*/
|
|
86
|
+
function isWalkable(v: unknown): v is Record<string, unknown> | unknown[] {
|
|
87
|
+
if (Array.isArray(v)) return true
|
|
88
|
+
if (v === null || typeof v !== 'object') return false
|
|
89
|
+
const proto = Object.getPrototypeOf(v)
|
|
90
|
+
return proto === Object.prototype || proto === null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Depth cap: Telegram payloads are shallow (deepest real nesting is
|
|
94
|
+
* `reply_markup.inline_keyboard[][]`, depth 3). The cap is a cheap guard
|
|
95
|
+
* against a pathological or cyclic input, never reached in practice. */
|
|
96
|
+
const MAX_DEPTH = 16
|
|
97
|
+
|
|
98
|
+
/** Mutable tally threaded through the walk so the caller can log HOW MANY
|
|
99
|
+
* code units were repaired without ever seeing the content itself. */
|
|
100
|
+
export interface SanitizeStats {
|
|
101
|
+
/** Total unpaired surrogate code units replaced with U+FFFD. */
|
|
102
|
+
repaired: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Define `k` as an own enumerable data property of `out`.
|
|
107
|
+
*
|
|
108
|
+
* `out[k] = v` would REASSIGN the clone's prototype for the single key
|
|
109
|
+
* `__proto__` (the `Object.prototype` accessor), silently dropping an own
|
|
110
|
+
* `__proto__` key from the payload. defineProperty always makes an own data
|
|
111
|
+
* property, for that key like any other.
|
|
112
|
+
*/
|
|
113
|
+
function defineOwn(out: Record<string, unknown>, k: string, v: unknown): void {
|
|
114
|
+
Object.defineProperty(out, k, {
|
|
115
|
+
value: v,
|
|
116
|
+
writable: true,
|
|
117
|
+
enumerable: true,
|
|
118
|
+
configurable: true,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Deep-sanitise every string in `value`, CLONE-ON-WRITE: the input is never
|
|
124
|
+
* mutated, and the exact same instance is returned when nothing changed.
|
|
125
|
+
*
|
|
126
|
+
* The clone is materialised LAZILY, on the first key/index that actually
|
|
127
|
+
* changed — so a clean payload allocates no container and performs no
|
|
128
|
+
* property definition at any depth, it is only walked and handed back by
|
|
129
|
+
* identity. That matters: this runs on the innermost hop of EVERY `bot.api.*`
|
|
130
|
+
* call, including the draft-stream `editMessageText` path that edits the same
|
|
131
|
+
* card several times a second, and the overwhelming majority of payloads are
|
|
132
|
+
* clean. (Pinned by the "clean nested payload" test, which fails if any
|
|
133
|
+
* container is rebuilt.)
|
|
134
|
+
*
|
|
135
|
+
* Pass `stats` to collect the repair count.
|
|
136
|
+
*/
|
|
137
|
+
export function sanitizePayloadStrings<T>(value: T, depth = 0, stats?: SanitizeStats): T {
|
|
138
|
+
if (typeof value === 'string') {
|
|
139
|
+
const next = sanitizeLoneSurrogates(value)
|
|
140
|
+
if (stats && next !== value) stats.repaired += countLoneSurrogates(value)
|
|
141
|
+
return next as unknown as T
|
|
142
|
+
}
|
|
143
|
+
if (depth >= MAX_DEPTH || !isWalkable(value)) return value
|
|
144
|
+
|
|
145
|
+
if (Array.isArray(value)) {
|
|
146
|
+
let out: unknown[] | undefined
|
|
147
|
+
for (let i = 0; i < value.length; i++) {
|
|
148
|
+
const item = value[i]
|
|
149
|
+
const next = sanitizePayloadStrings(item, depth + 1, stats)
|
|
150
|
+
// First change: copy the already-walked prefix, all of it unchanged.
|
|
151
|
+
if (out === undefined && next !== item) out = value.slice(0, i)
|
|
152
|
+
if (out !== undefined) out.push(next)
|
|
153
|
+
}
|
|
154
|
+
return (out ?? value) as unknown as T
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// `for...in` rather than `Object.entries`, so the clean path allocates no
|
|
158
|
+
// key/entry array either. The `hasOwnProperty` guard keeps this EXACTLY
|
|
159
|
+
// equivalent to `Object.entries`: `for...in` also yields INHERITED
|
|
160
|
+
// enumerable keys, so without it a polluted `Object.prototype` would leak an
|
|
161
|
+
// extra field into the body we hand to Telegram.
|
|
162
|
+
const hasOwn = Object.prototype.hasOwnProperty
|
|
163
|
+
let out: Record<string, unknown> | undefined
|
|
164
|
+
for (const k in value) {
|
|
165
|
+
if (!hasOwn.call(value, k)) continue
|
|
166
|
+
const v = (value as Record<string, unknown>)[k]
|
|
167
|
+
const next = sanitizePayloadStrings(v, depth + 1, stats)
|
|
168
|
+
if (out === undefined && next !== v) {
|
|
169
|
+
// First change: materialise the clone and backfill the keys already
|
|
170
|
+
// walked, every one of which came back unchanged. This re-reads those
|
|
171
|
+
// properties (so an own getter in the prefix is invoked twice), which
|
|
172
|
+
// only ever happens on the repair path — a payload with an own getter
|
|
173
|
+
// AND a lone surrogate. Telegram payloads are plain data; a getter that
|
|
174
|
+
// throws is caught by the fail-open guard in `installUtf8Sanitizer`.
|
|
175
|
+
out = {}
|
|
176
|
+
for (const prev in value) {
|
|
177
|
+
if (prev === k) break
|
|
178
|
+
if (!hasOwn.call(value, prev)) continue
|
|
179
|
+
defineOwn(out, prev, (value as Record<string, unknown>)[prev])
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (out !== undefined) defineOwn(out, k, next)
|
|
183
|
+
}
|
|
184
|
+
return (out ?? value) as unknown as T
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One log line per method per minute. The draft-stream `editMessageText`
|
|
188
|
+
* path edits the same card many times a second, so an upstream that is
|
|
189
|
+
* PERSISTENTLY corrupt would otherwise emit a line per edit and drown the
|
|
190
|
+
* gateway log in the exact situation you most need to read it. */
|
|
191
|
+
const LOG_THROTTLE_MS = 60_000
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Install the UTF-8 sanitiser as a grammy API transformer.
|
|
195
|
+
*
|
|
196
|
+
* MUST be installed FIRST, before every other transformer: grammy composes
|
|
197
|
+
* `call = trans(prev, ...)` (`grammy/out/core/client.js:9-11,91`), so the
|
|
198
|
+
* LAST-installed transformer is the OUTERMOST and the FIRST-installed is the
|
|
199
|
+
* INNERMOST — the one that sees the final payload immediately before it is
|
|
200
|
+
* serialised and POSTed. Anything installed after this one can therefore not
|
|
201
|
+
* reintroduce a lone surrogate behind its back.
|
|
202
|
+
*
|
|
203
|
+
* A repair is logged with the method and the repaired code-unit COUNT — never
|
|
204
|
+
* the body, which may carry operator content — so a corrupt upstream producer
|
|
205
|
+
* stays diagnosable instead of being silently papered over. The line is
|
|
206
|
+
* throttled to once per method per minute.
|
|
207
|
+
*
|
|
208
|
+
* The sanitise call FAILS OPEN. It runs on the innermost hop of every single
|
|
209
|
+
* `bot.api.*` call, and its object walk reads own enumerable properties —
|
|
210
|
+
* which invokes getters. A throwing getter, or any future bug in the walk,
|
|
211
|
+
* would otherwise propagate out of every outbound call and wedge the whole
|
|
212
|
+
* gateway. Sending the original payload is never worse than throwing: the
|
|
213
|
+
* worst case is the pre-#4728 behaviour (Telegram 400s that one send), while
|
|
214
|
+
* throwing here breaks sends that had nothing wrong with them. It is not
|
|
215
|
+
* silent, though: failing open logs its own throttled line naming the method,
|
|
216
|
+
* so the degraded state stays diagnosable.
|
|
217
|
+
*
|
|
218
|
+
* `now` is an injected clock so the throttle can be driven deterministically
|
|
219
|
+
* from a test: this file runs under BOTH vitest and `bun test`, and bun's
|
|
220
|
+
* `vi` has no `setSystemTime`.
|
|
221
|
+
*/
|
|
222
|
+
export function installUtf8Sanitizer(bot: Bot, now: () => number = Date.now): void {
|
|
223
|
+
// Throttle state is per-install, not module-global, so one bot's log budget
|
|
224
|
+
// cannot be consumed by another (and a test needs no reset hook).
|
|
225
|
+
const lastLoggedAtByMethod = new Map<string, number>()
|
|
226
|
+
const shouldLogRepair = (method: string, at: number): boolean => {
|
|
227
|
+
const last = lastLoggedAtByMethod.get(method)
|
|
228
|
+
if (last !== undefined && at - last < LOG_THROTTLE_MS) return false
|
|
229
|
+
lastLoggedAtByMethod.set(method, at)
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
bot.api.config.use(async (prev, method, payload, signal) => {
|
|
234
|
+
let clean = payload
|
|
235
|
+
const stats: SanitizeStats = { repaired: 0 }
|
|
236
|
+
try {
|
|
237
|
+
clean = sanitizePayloadStrings(payload, 0, stats)
|
|
238
|
+
} catch (err) {
|
|
239
|
+
clean = payload
|
|
240
|
+
// Fail open, but never SILENTLY: without this line a future walk bug or
|
|
241
|
+
// a genuinely throwing getter degrades to a bare Telegram 400 with no
|
|
242
|
+
// trail back to the sanitiser — precisely the opacity #4728 exists to
|
|
243
|
+
// end. Throttled on its own budget (a distinct key), so a persistent
|
|
244
|
+
// walk failure cannot be starved by, or starve, the repair line above.
|
|
245
|
+
// The error MESSAGE is included because without it the line cannot
|
|
246
|
+
// diagnose anything; the payload body still never is.
|
|
247
|
+
// The separator is written as the ESCAPE "backslash-u-0000", never as a raw NUL
|
|
248
|
+
// byte: a literal 0x00 in a source file trips the #3676 binary-file
|
|
249
|
+
// guard (tests/source-files-are-text.test.ts) and blocks the merge
|
|
250
|
+
// queue. The runtime key is identical either way.
|
|
251
|
+
if (shouldLogRepair(`${method}\u0000sanitize-failed`, now())) {
|
|
252
|
+
process.stderr.write(
|
|
253
|
+
`telegram gateway: utf8-sanitize FAILED OPEN method=${method} — ` +
|
|
254
|
+
`payload sent unrepaired; if it carries a lone surrogate Telegram ` +
|
|
255
|
+
`will reject it (400 "strings must be encoded in UTF-8"): ` +
|
|
256
|
+
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (clean !== payload && shouldLogRepair(method, now())) {
|
|
261
|
+
process.stderr.write(
|
|
262
|
+
`telegram gateway: utf8-sanitize repaired ${stats.repaired} lone surrogate(s) ` +
|
|
263
|
+
`method=${method} — body would have been rejected by Telegram ` +
|
|
264
|
+
`(400 "strings must be encoded in UTF-8")\n`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
return prev(method, clean, signal)
|
|
268
|
+
})
|
|
269
|
+
}
|