switchroom 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/dist/agent-scheduler/index.js +218 -99
- package/dist/auth-broker/index.js +300 -99
- package/dist/cli/drive-write-pretool.mjs +45 -12
- package/dist/cli/switchroom.js +44972 -42457
- package/dist/cli/ui/index.html +1281 -0
- package/dist/host-control/main.js +3630 -311
- package/dist/vault/approvals/kernel-server.js +209 -100
- package/dist/vault/broker/server.js +220 -99
- package/examples/personal-google-workspace-mcp/README.md +8 -3
- package/examples/switchroom.yaml +91 -42
- package/package.json +2 -2
- package/profiles/_base/start.sh.hbs +76 -36
- package/profiles/default/CLAUDE.md.hbs +4 -2
- package/skills/file-bug/SKILL.md +6 -4
- package/skills/switchroom-cli/SKILL.md +20 -4
- package/skills/switchroom-install/SKILL.md +3 -3
- package/telegram-plugin/auth-snapshot-format.ts +4 -4
- package/telegram-plugin/auto-fallback-fleet.ts +4 -4
- package/telegram-plugin/card-format.ts +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1029 -628
- package/telegram-plugin/dist/server.js +162 -161
- package/telegram-plugin/format.ts +71 -0
- package/telegram-plugin/gateway/approval-card.test.ts +18 -18
- package/telegram-plugin/gateway/approval-card.ts +1 -1
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
- package/telegram-plugin/gateway/auth-command.ts +12 -2
- package/telegram-plugin/gateway/boot-card.ts +40 -3
- package/telegram-plugin/gateway/boot-probes.ts +71 -27
- package/telegram-plugin/gateway/diff-preview-card.test.ts +15 -15
- package/telegram-plugin/gateway/diff-preview-card.ts +1 -1
- package/telegram-plugin/gateway/drive-write-approval.test.ts +2 -2
- package/telegram-plugin/gateway/gateway.ts +244 -46
- package/telegram-plugin/gateway/hostd-dispatch.ts +10 -2
- package/telegram-plugin/gateway/update-announce.ts +167 -0
- package/telegram-plugin/quota-check.ts +0 -195
- package/telegram-plugin/retry-api-call.ts +24 -0
- package/telegram-plugin/server.ts +8 -5
- package/telegram-plugin/tests/auth-add-flow.test.ts +31 -2
- package/telegram-plugin/tests/boot-probes.test.ts +53 -0
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
- package/telegram-plugin/tests/quota-check.test.ts +0 -409
- package/telegram-plugin/tests/retry-api-call.test.ts +76 -0
- package/telegram-plugin/tests/telegram-format.test.ts +84 -1
- package/telegram-plugin/tests/update-announce.test.ts +154 -0
- package/telegram-plugin/welcome-text.ts +1 -8
- package/profiles/default/CLAUDE.md +0 -192
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/telegram-plugin/first-paint.ts +0 -225
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/telegram-plugin/server.js +0 -41795
- package/telegram-plugin/tests/html-balanced.ts +0 -63
- package/telegram-plugin/tests/snapshot-serializer.ts +0 -79
- package/telegram-plugin/tool-error-filter.ts +0 -89
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update-flow PR C — boot-card surfacing for MCP-originated updates.
|
|
3
|
+
*
|
|
4
|
+
* When an agent (e.g. klanker) runs `mcp__hostd__update_apply` the work
|
|
5
|
+
* happens hostd-side, the agent itself restarts at the end, and the
|
|
6
|
+
* resulting boot card for THIS agent (the one that just restarted via
|
|
7
|
+
* the redeploy) needs to surface the outcome — success or failure with a
|
|
8
|
+
* recovery hint — so the operator sees what happened without trawling
|
|
9
|
+
* the audit log.
|
|
10
|
+
*
|
|
11
|
+
* Implementation: on boot, scan ~/.switchroom/host-control-audit.log for
|
|
12
|
+
* the most recent `phase: "terminal"` `update_apply` row within a recent
|
|
13
|
+
* window. Dedupe via an atomic O_EXCL marker so a respawn within the
|
|
14
|
+
* window doesn't re-announce. Render a single line that's appended to
|
|
15
|
+
* the existing boot-card body.
|
|
16
|
+
*
|
|
17
|
+
* Pure & test-friendly — `readLastTerminalUpdateAudit` accepts an
|
|
18
|
+
* injectable file-reader, `renderUpdateOutcomeLine` is a pure function,
|
|
19
|
+
* and `claimUpdateAnnouncement` accepts an injectable claim-dir + clock.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, openSync, closeSync, readFileSync } from 'node:fs'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { homedir } from 'node:os'
|
|
25
|
+
import { readAndFilter, defaultAuditLogPath, type AuditEntry } from '../../src/host-control/audit-reader.js'
|
|
26
|
+
|
|
27
|
+
/** Default lookback window: 10 minutes is enough to catch the boot that
|
|
28
|
+
* follows a normal update_apply but small enough that an audit row from
|
|
29
|
+
* yesterday's run can't re-trigger an announcement after a long
|
|
30
|
+
* outage. */
|
|
31
|
+
export const DEFAULT_LOOKBACK_MS = 10 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
export interface ReadOpts {
|
|
34
|
+
/** Read the file system. Override in tests. */
|
|
35
|
+
readFile?: (path: string) => string
|
|
36
|
+
/** Exists check. Override in tests. */
|
|
37
|
+
exists?: (path: string) => boolean
|
|
38
|
+
/** Override the audit-log path (defaults to ~/.switchroom/host-control-audit.log). */
|
|
39
|
+
auditLogPath?: string
|
|
40
|
+
/** Wall-clock for the lookback comparison. */
|
|
41
|
+
now?: number
|
|
42
|
+
/** Lookback window in ms. Defaults to {@link DEFAULT_LOOKBACK_MS}. */
|
|
43
|
+
lookbackMs?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the audit log and return the most-recent `update_apply` terminal
|
|
48
|
+
* row within the lookback window, or null if none. We deliberately do
|
|
49
|
+
* not filter by caller — any update_apply outcome is interesting to the
|
|
50
|
+
* person watching this chat regardless of which agent ran the verb.
|
|
51
|
+
*/
|
|
52
|
+
export function readLastTerminalUpdateAudit(opts: ReadOpts = {}): AuditEntry | null {
|
|
53
|
+
const path = opts.auditLogPath ?? defaultAuditLogPath()
|
|
54
|
+
const exists = opts.exists ?? existsSync
|
|
55
|
+
const readFile = opts.readFile ?? ((p: string) => readFileSync(p, 'utf-8'))
|
|
56
|
+
if (!exists(path)) return null
|
|
57
|
+
let raw: string
|
|
58
|
+
try {
|
|
59
|
+
raw = readFile(path)
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
// Pull recent update_apply rows, then trim to terminal-phase within window.
|
|
64
|
+
const recent = readAndFilter(raw, { op: 'update_apply' }, 200)
|
|
65
|
+
const now = opts.now ?? Date.now()
|
|
66
|
+
const since = now - (opts.lookbackMs ?? DEFAULT_LOOKBACK_MS)
|
|
67
|
+
let best: AuditEntry | null = null
|
|
68
|
+
for (const e of recent) {
|
|
69
|
+
if (e.phase !== 'terminal') continue
|
|
70
|
+
const ts = Date.parse(e.ts)
|
|
71
|
+
if (Number.isNaN(ts)) continue
|
|
72
|
+
if (ts < since) continue
|
|
73
|
+
if (best == null || Date.parse(best.ts) < ts) best = e
|
|
74
|
+
}
|
|
75
|
+
return best
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const RECOVERY_HINTS: Record<string, string> = {
|
|
79
|
+
binary:
|
|
80
|
+
'curl https://switchroom.ai/install.sh | sh && switchroom update',
|
|
81
|
+
source:
|
|
82
|
+
'cd ~/code/switchroom && git pull && bun install && bun run build && switchroom update',
|
|
83
|
+
'source-unlinked':
|
|
84
|
+
'cd ~/code/switchroom && bun link && switchroom update # ensures binary is in PATH first',
|
|
85
|
+
docker:
|
|
86
|
+
'docker compose -p switchroom pull && docker compose -p switchroom up -d',
|
|
87
|
+
unknown:
|
|
88
|
+
'Cannot auto-detect install type. Run `switchroom apply` to refresh ~/.switchroom/install-type.json, then retry.',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function recoveryHint(installType: string | undefined): string {
|
|
92
|
+
if (!installType) return RECOVERY_HINTS.unknown
|
|
93
|
+
return RECOVERY_HINTS[installType] ?? RECOVERY_HINTS.unknown
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function shortSha(s: string): string {
|
|
97
|
+
return s.replace(/^sha256:/, '').slice(0, 12)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Pure renderer. Returns the single line (HTML-safe — plain ASCII)
|
|
102
|
+
* to append to the boot card body. `null` means nothing to surface
|
|
103
|
+
* (entry too stale, schema invalid, etc.).
|
|
104
|
+
*/
|
|
105
|
+
export function renderUpdateOutcomeLine(entry: AuditEntry): string {
|
|
106
|
+
const success = entry.exit_code === 0 && entry.result !== 'error' && entry.result !== 'denied'
|
|
107
|
+
if (success) {
|
|
108
|
+
const channel = entry.channel ? `channel:${entry.channel}` : entry.pin ? `pin:${entry.pin}` : 'channel:?'
|
|
109
|
+
let shaStr = ''
|
|
110
|
+
if (entry.resolved_sha) {
|
|
111
|
+
const firstSha = Object.values(entry.resolved_sha)[0]
|
|
112
|
+
if (firstSha) shaStr = `, sha:${shortSha(firstSha)}`
|
|
113
|
+
}
|
|
114
|
+
return `✅ update completed (${channel}${shaStr})`
|
|
115
|
+
}
|
|
116
|
+
const stderrTail = (entry.stderr_tail ?? entry.error ?? '').slice(-400)
|
|
117
|
+
const opStep = entry.op
|
|
118
|
+
const hint = recoveryHint(entry.install_context?.install_type)
|
|
119
|
+
// Single line is reader-friendly when short; multi-line when stderr is present.
|
|
120
|
+
const lines = [`❌ update failed at ${opStep}: ${stderrTail || '(no stderr captured)'}`, ` ↳ Recovery: ${hint}`]
|
|
121
|
+
return lines.join('\n')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ClaimOpts {
|
|
125
|
+
/** Override state-dir base (default: $TELEGRAM_STATE_DIR or ~/.switchroom/<agent>/telegram). */
|
|
126
|
+
stateDir?: string
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Atomic claim via `O_CREAT|O_EXCL` — returns true if THIS process is
|
|
131
|
+
* the first to announce this request_id. Idempotent across respawns
|
|
132
|
+
* within the same state-dir. We deliberately don't clean up old
|
|
133
|
+
* markers — they're tiny and bounded by the lookback window above.
|
|
134
|
+
*/
|
|
135
|
+
export function claimUpdateAnnouncement(requestId: string, opts: ClaimOpts = {}): boolean {
|
|
136
|
+
const stateDir = opts.stateDir ?? process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.switchroom')
|
|
137
|
+
const dir = join(stateDir, 'update-announced')
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(dir, { recursive: true })
|
|
140
|
+
} catch {
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
const safeId = requestId.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 200)
|
|
144
|
+
const path = join(dir, safeId)
|
|
145
|
+
try {
|
|
146
|
+
// O_CREAT | O_EXCL — fails with EEXIST if another boot already
|
|
147
|
+
// claimed this request_id.
|
|
148
|
+
const fd = openSync(path, 'wx')
|
|
149
|
+
closeSync(fd)
|
|
150
|
+
return true
|
|
151
|
+
} catch {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Combined entry-point used by the gateway boot path: read + claim +
|
|
158
|
+
* render. Returns the line to append (already escaped for HTML — plain
|
|
159
|
+
* ASCII text — caller embeds with no further processing) or null when
|
|
160
|
+
* there's nothing to surface OR another boot already claimed the row.
|
|
161
|
+
*/
|
|
162
|
+
export function maybeRenderUpdateAnnouncement(opts: ReadOpts & ClaimOpts = {}): string | null {
|
|
163
|
+
const entry = readLastTerminalUpdateAudit(opts)
|
|
164
|
+
if (!entry) return null
|
|
165
|
+
if (!claimUpdateAnnouncement(entry.request_id, opts)) return null
|
|
166
|
+
return renderUpdateOutcomeLine(entry)
|
|
167
|
+
}
|
|
@@ -256,198 +256,3 @@ export function formatQuotaBlock(q: QuotaUtilization, now: Date = new Date()): s
|
|
|
256
256
|
return lines.join("\n");
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
/* ── Account-level quota probe + short-lived cache ───────────────────── */
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Resolve an account's OAuth access token from
|
|
263
|
-
* `~/.switchroom/accounts/<label>/credentials.json` (new account model
|
|
264
|
-
* — see `reference/share-auth-across-the-fleet.md`). Returns null when
|
|
265
|
-
* the file is missing, malformed, or has no accessToken — caller
|
|
266
|
-
* surfaces a graceful "missing credentials" badge.
|
|
267
|
-
*
|
|
268
|
-
* Exported for unit testing; production callers go through
|
|
269
|
-
* {@link fetchAccountQuota}.
|
|
270
|
-
*/
|
|
271
|
-
export function readAccountAccessToken(
|
|
272
|
-
label: string,
|
|
273
|
-
home: string = (process.env.HOME ?? "/root"),
|
|
274
|
-
): string | null {
|
|
275
|
-
const credPath = join(home, ".switchroom", "accounts", label, "credentials.json");
|
|
276
|
-
if (!existsSync(credPath)) return null;
|
|
277
|
-
try {
|
|
278
|
-
const raw = readFileSync(credPath, "utf-8");
|
|
279
|
-
const parsed = JSON.parse(raw) as {
|
|
280
|
-
claudeAiOauth?: { accessToken?: string };
|
|
281
|
-
};
|
|
282
|
-
const token = parsed.claudeAiOauth?.accessToken?.trim();
|
|
283
|
-
return token && token.length > 0 ? token : null;
|
|
284
|
-
} catch {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Cache key per account label. The cached entry holds the result and
|
|
291
|
-
* the wall-clock timestamp it was fetched at, so the dashboard tap
|
|
292
|
-
* pattern (refresh-on-tap) doesn't trigger a fresh API call within the
|
|
293
|
-
* TTL window. Quota numbers don't move within a few seconds anyway.
|
|
294
|
-
*/
|
|
295
|
-
type AccountQuotaCacheEntry = {
|
|
296
|
-
fetchedAt: number;
|
|
297
|
-
result: QuotaResult;
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
/** TTL for the per-account quota cache — controls when
|
|
301
|
-
* `prefetchAccountQuotaIfStale` re-probes Anthropic and when
|
|
302
|
-
* `fetchAccountQuota`'s cache-bypass kicks in. 5 min: quota numbers
|
|
303
|
-
* don't move within a few minutes for human-scale usage; the
|
|
304
|
-
* prefetch fires on every dashboard render so the cache stays fresh
|
|
305
|
-
* whenever the operator interacts. The dashboard's sync read
|
|
306
|
-
* (`getCachedAccountQuota`) returns last-known data regardless of
|
|
307
|
-
* staleness — see that function's docstring for why. */
|
|
308
|
-
export const ACCOUNT_QUOTA_CACHE_TTL_MS = 5 * 60_000;
|
|
309
|
-
|
|
310
|
-
const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Fetch quota for a global account by label. Wraps {@link fetchQuota}
|
|
314
|
-
* with token-resolution (`~/.switchroom/accounts/<label>/credentials.json`)
|
|
315
|
-
* and a short-lived in-process cache so repeat dashboard taps within
|
|
316
|
-
* the TTL don't re-hit the Anthropic API.
|
|
317
|
-
*
|
|
318
|
-
* Pass `force: true` to bypass the cache (used when the user
|
|
319
|
-
* explicitly taps "📊 Full quota" — they expect a live read).
|
|
320
|
-
*/
|
|
321
|
-
export async function fetchAccountQuota(
|
|
322
|
-
label: string,
|
|
323
|
-
opts: {
|
|
324
|
-
home?: string;
|
|
325
|
-
force?: boolean;
|
|
326
|
-
now?: () => number;
|
|
327
|
-
fetchImpl?: typeof fetch;
|
|
328
|
-
timeoutMs?: number;
|
|
329
|
-
} = {},
|
|
330
|
-
): Promise<QuotaResult> {
|
|
331
|
-
const now = opts.now?.() ?? Date.now();
|
|
332
|
-
if (!opts.force) {
|
|
333
|
-
const cached = accountQuotaCache.get(label);
|
|
334
|
-
if (cached && now - cached.fetchedAt < ACCOUNT_QUOTA_CACHE_TTL_MS) {
|
|
335
|
-
return cached.result;
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const token = readAccountAccessToken(label, opts.home);
|
|
340
|
-
if (!token) {
|
|
341
|
-
const result: QuotaResult = {
|
|
342
|
-
ok: false,
|
|
343
|
-
reason: "no credentials.json or accessToken for account",
|
|
344
|
-
};
|
|
345
|
-
accountQuotaCache.set(label, { fetchedAt: now, result });
|
|
346
|
-
return result;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const result = await fetchQuota({
|
|
350
|
-
accessToken: token,
|
|
351
|
-
fetchImpl: opts.fetchImpl,
|
|
352
|
-
timeoutMs: opts.timeoutMs,
|
|
353
|
-
});
|
|
354
|
-
accountQuotaCache.set(label, { fetchedAt: now, result });
|
|
355
|
-
// Note: pre-RFC-H this also persisted to disk via writeAccountQuota
|
|
356
|
-
// (#708) so a gateway restart could re-hydrate without an API call.
|
|
357
|
-
// Post-RFC-H the broker holds canonical quota state and answers
|
|
358
|
-
// via `list-state`, so the gateway's in-process cache is enough.
|
|
359
|
-
return result;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Re-hydrate the in-process account-quota cache from on-disk
|
|
364
|
-
* snapshots written by previous gateway lifetimes (issue #708).
|
|
365
|
-
* Called once at gateway boot so the boot card and the first /auth
|
|
366
|
-
* tap have data instantly — no need to wait for the background
|
|
367
|
-
* prefetch tick.
|
|
368
|
-
*
|
|
369
|
-
* Safe to call repeatedly: each label is set to the disk snapshot's
|
|
370
|
-
* `capturedAt` timestamp so a fresher live probe still wins on
|
|
371
|
-
* `now - fetchedAt < TTL` comparisons. When the disk snapshot is
|
|
372
|
-
* older than the TTL, the cache entry is still seeded — the background
|
|
373
|
-
* prefetch will replace it on the next tap.
|
|
374
|
-
*/
|
|
375
|
-
export function hydrateAccountQuotaCacheFromDisk(
|
|
376
|
-
_labels: ReadonlyArray<string>,
|
|
377
|
-
_home?: string,
|
|
378
|
-
): void {
|
|
379
|
-
// No-op post-RFC-H. The disk-snapshot store this function used to
|
|
380
|
-
// re-hydrate from (per-account quota.json files under
|
|
381
|
-
// ~/.switchroom/accounts/<label>/) is gone — switchroom-auth-broker
|
|
382
|
-
// now owns canonical quota state. Boot-time hydration is the
|
|
383
|
-
// broker's `list-state` call instead. Signature preserved so
|
|
384
|
-
// existing call sites continue to compile while we phase them out.
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** Test/utility helper — wipe the per-account quota cache. The
|
|
388
|
-
* gateway calls this on auth-account-level mutations (account add,
|
|
389
|
-
* account rm, account rename, refresh-accounts tick) so a stale
|
|
390
|
-
* pre-rename label doesn't survive into the dashboard. */
|
|
391
|
-
export function clearAccountQuotaCache(label?: string): void {
|
|
392
|
-
if (label == null) {
|
|
393
|
-
accountQuotaCache.clear();
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
accountQuotaCache.delete(label);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Sync read of the account quota cache. Returns whatever's cached for
|
|
401
|
-
* this label — `null` only when there's NO entry at all. Stale-but-
|
|
402
|
-
* present cache entries are returned on purpose:
|
|
403
|
-
*
|
|
404
|
-
* - The dashboard renders sync; awaiting a fresh probe would block
|
|
405
|
-
* the user-visible message (and a probe can stall on Anthropic
|
|
406
|
-
* latency or network).
|
|
407
|
-
* - Showing yesterday's number is dramatically better UX than
|
|
408
|
-
* showing nothing — quota changes slowly enough that "the cached
|
|
409
|
-
* value" is almost always close to truth.
|
|
410
|
-
* - The background prefetch (`prefetchAccountQuotaIfStale`) keeps
|
|
411
|
-
* the cache fresh across renders. Within the 5-min TTL it
|
|
412
|
-
* no-ops; past the TTL it kicks off a fresh probe whose result
|
|
413
|
-
* is visible on the operator's NEXT render (refresh tap or
|
|
414
|
-
* auto-refresh after an action).
|
|
415
|
-
*
|
|
416
|
-
* Pre-v0.6.11 this function treated stale entries as a miss, which
|
|
417
|
-
* meant the boot-warmed cache vanished after 30s and the operator
|
|
418
|
-
* saw empty quota rows on the first /auth tap of any day after the
|
|
419
|
-
* gateway restart. That's the bug this docstring exists to keep
|
|
420
|
-
* fixed.
|
|
421
|
-
*/
|
|
422
|
-
export function getCachedAccountQuota(
|
|
423
|
-
_label: string,
|
|
424
|
-
_now: number = Date.now(),
|
|
425
|
-
): QuotaResult | null {
|
|
426
|
-
// Note the unused params — we keep the signature stable for callers
|
|
427
|
-
// that pass `now` (test helpers) even though we no longer use it.
|
|
428
|
-
const cached = accountQuotaCache.get(_label);
|
|
429
|
-
if (!cached) return null;
|
|
430
|
-
return cached.result;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Fire-and-forget background prefetch — kicks off
|
|
435
|
-
* `fetchAccountQuota` if the cache is cold/stale and discards the
|
|
436
|
-
* promise. Safe to call on every dashboard render: the cache TTL
|
|
437
|
-
* keeps the API call rate bounded to ~1 per account per 30s
|
|
438
|
-
* regardless of how many times the user taps /auth.
|
|
439
|
-
*
|
|
440
|
-
* Errors are swallowed (the next tap re-tries via the cache miss
|
|
441
|
-
* path); the dashboard's empty quota row is the user-visible
|
|
442
|
-
* "didn't probe yet" signal.
|
|
443
|
-
*/
|
|
444
|
-
export function prefetchAccountQuotaIfStale(
|
|
445
|
-
label: string,
|
|
446
|
-
opts: { home?: string; now?: () => number; fetchImpl?: typeof fetch } = {},
|
|
447
|
-
): void {
|
|
448
|
-
const now = opts.now?.() ?? Date.now();
|
|
449
|
-
const cached = accountQuotaCache.get(label);
|
|
450
|
-
if (cached && now - cached.fetchedAt < ACCOUNT_QUOTA_CACHE_TTL_MS) return;
|
|
451
|
-
// Don't await — background warm.
|
|
452
|
-
void fetchAccountQuota(label, opts).catch(() => {});
|
|
453
|
-
}
|
|
@@ -250,3 +250,27 @@ export async function retryWithThreadFallback<T>(
|
|
|
250
250
|
throw err
|
|
251
251
|
}
|
|
252
252
|
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* True when Telegram rejected a message because it couldn't parse the
|
|
256
|
+
* HTML/entities we sent — our prevention (markdownToHtml +
|
|
257
|
+
* sanitizeForTelegram + splitHtmlChunks) let something malformed
|
|
258
|
+
* through anyway. These 400s are deliberately NOT swallowed or retried
|
|
259
|
+
* by `retryApiCall` (only not-modified / not-found / thread-not-found
|
|
260
|
+
* are) — they surface to the caller, which recovers by resending the
|
|
261
|
+
* chunk as plain text (parse_mode unset). Same "caller-level fallback"
|
|
262
|
+
* shape as the THREAD_NOT_FOUND contract above.
|
|
263
|
+
*/
|
|
264
|
+
export function isHtmlParseRejectError(err: unknown): boolean {
|
|
265
|
+
if (!(err instanceof GrammyError) || err.error_code !== 400) return false
|
|
266
|
+
const d = (err.description || '').toLowerCase()
|
|
267
|
+
return (
|
|
268
|
+
d.includes("can't parse entities") ||
|
|
269
|
+
d.includes('can’t parse entities') ||
|
|
270
|
+
d.includes('unsupported start tag') ||
|
|
271
|
+
d.includes('unclosed start tag') ||
|
|
272
|
+
d.includes("can't find end of the entity") ||
|
|
273
|
+
// covers both "expected end tag" and "unexpected end tag"
|
|
274
|
+
d.includes('expected end tag')
|
|
275
|
+
)
|
|
276
|
+
}
|
|
@@ -159,13 +159,16 @@ installPluginLogger()
|
|
|
159
159
|
// missing-feature bugs (no /issues card, no quota notifications, no
|
|
160
160
|
// graceful failover). A clean error is more honest.
|
|
161
161
|
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// `
|
|
162
|
+
// In v0.7+ the gateway is an in-container sidecar started by start.sh
|
|
163
|
+
// (no systemd unit). On a Docker host inspect/recover with
|
|
164
|
+
// `docker logs switchroom-<agent>` / `switchroom agent restart <agent>`;
|
|
165
|
+
// on a legacy non-docker install it's the gateway systemd user unit.
|
|
166
|
+
const recover = process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
167
|
+
? 'check `docker logs switchroom-<agent>` then `switchroom agent restart <agent>`'
|
|
168
|
+
: 'run `switchroom setup` to install the gateway, or check `systemctl --user status switchroom-telegram-gateway`'
|
|
165
169
|
process.stderr.write(
|
|
166
170
|
`telegram channel: no gateway socket at ${_gatewaySocket}. ` +
|
|
167
|
-
`
|
|
168
|
-
`\`systemctl --user status switchroom-telegram-gateway\`. Exiting sidecar.\n`,
|
|
171
|
+
`The gateway sidecar is not running — ${recover}. Exiting sidecar.\n`,
|
|
169
172
|
)
|
|
170
173
|
process.exit(1)
|
|
171
174
|
}
|
|
@@ -26,10 +26,39 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
29
|
-
import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
29
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
|
30
30
|
import { tmpdir } from 'node:os'
|
|
31
31
|
import { join } from 'node:path'
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Pick an exec-allowed temp root. Some containers (and this one) mount
|
|
35
|
+
* /tmp with `noexec`, which breaks the subprocess fixtures that spawn
|
|
36
|
+
* a small node script as a stand-in for `claude setup-token`. When the
|
|
37
|
+
* default tmpdir is noexec, fall back to a project-local `.test-tmp/`
|
|
38
|
+
* which inherits the project mount's exec bits.
|
|
39
|
+
*/
|
|
40
|
+
function execAllowedTmpdir(): string {
|
|
41
|
+
const def = tmpdir()
|
|
42
|
+
try {
|
|
43
|
+
// Read /proc/mounts and check whether the directory's mount has noexec.
|
|
44
|
+
const mounts = readFileSync('/proc/mounts', 'utf8')
|
|
45
|
+
const noexec = mounts.split('\n').some((line) => {
|
|
46
|
+
const parts = line.split(' ')
|
|
47
|
+
if (parts.length < 4) return false
|
|
48
|
+
const [, mountPoint, , opts] = parts
|
|
49
|
+
return mountPoint === def && opts.split(',').includes('noexec')
|
|
50
|
+
})
|
|
51
|
+
if (!noexec) return def
|
|
52
|
+
} catch {
|
|
53
|
+
return def
|
|
54
|
+
}
|
|
55
|
+
const fallback = join(process.cwd(), '.test-tmp')
|
|
56
|
+
mkdirSync(fallback, { recursive: true })
|
|
57
|
+
return fallback
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const EXEC_TMPDIR = execAllowedTmpdir()
|
|
61
|
+
|
|
33
62
|
import {
|
|
34
63
|
parseAuthCommand,
|
|
35
64
|
handleAuthCommand,
|
|
@@ -51,7 +80,7 @@ import {
|
|
|
51
80
|
let workspace: string
|
|
52
81
|
|
|
53
82
|
beforeEach(() => {
|
|
54
|
-
workspace = mkdtempSync(join(
|
|
83
|
+
workspace = mkdtempSync(join(EXEC_TMPDIR, 'auth-add-flow-test-'))
|
|
55
84
|
pendingAuthAddFlows.clear()
|
|
56
85
|
})
|
|
57
86
|
|
|
@@ -292,6 +292,59 @@ describe('probeQuota — #1163: /v1/messages headers path', () => {
|
|
|
292
292
|
expect(result.detail).toContain('18% / 7d')
|
|
293
293
|
})
|
|
294
294
|
|
|
295
|
+
it('prefers the broker probe and does NOT do a direct fetch when it succeeds (Option A / #1336)', async () => {
|
|
296
|
+
const directFetch: typeof fetch = async () => {
|
|
297
|
+
throw new Error('direct fetch must not run when the broker probe returns a result')
|
|
298
|
+
}
|
|
299
|
+
const brokerProbe = async () => ({
|
|
300
|
+
ok: true as const,
|
|
301
|
+
data: {
|
|
302
|
+
fiveHourUtilizationPct: 30,
|
|
303
|
+
sevenDayUtilizationPct: 12,
|
|
304
|
+
fiveHourResetAt: null,
|
|
305
|
+
sevenDayResetAt: null,
|
|
306
|
+
representativeClaim: null,
|
|
307
|
+
overageStatus: null,
|
|
308
|
+
overageDisabledReason: null,
|
|
309
|
+
},
|
|
310
|
+
})
|
|
311
|
+
const result = await probeQuota(claudeDir, agentDir, directFetch, { brokerProbe })
|
|
312
|
+
expect(result.status).toBe('ok')
|
|
313
|
+
expect(result.detail).toContain('30% / 5h')
|
|
314
|
+
expect(result.detail).toContain('12% / 7d')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('falls back to a direct probe when the broker probe returns null (broker unreachable)', async () => {
|
|
318
|
+
const headers = new Headers({
|
|
319
|
+
'anthropic-ratelimit-unified-5h-utilization': '0.55',
|
|
320
|
+
'anthropic-ratelimit-unified-7d-utilization': '0.22',
|
|
321
|
+
})
|
|
322
|
+
const directFetch: typeof fetch = async () =>
|
|
323
|
+
new Response('{}', { status: 200, headers }) as Response
|
|
324
|
+
const brokerProbe = async () => null
|
|
325
|
+
|
|
326
|
+
const result = await probeQuota(claudeDir, agentDir, directFetch, { brokerProbe })
|
|
327
|
+
expect(result.status).toBe('ok')
|
|
328
|
+
expect(result.detail).toContain('55% / 5h')
|
|
329
|
+
expect(result.detail).toContain('22% / 7d')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('falls back to a direct probe when the broker probe throws', async () => {
|
|
333
|
+
const headers = new Headers({
|
|
334
|
+
'anthropic-ratelimit-unified-5h-utilization': '0.10',
|
|
335
|
+
'anthropic-ratelimit-unified-7d-utilization': '0.05',
|
|
336
|
+
})
|
|
337
|
+
const directFetch: typeof fetch = async () =>
|
|
338
|
+
new Response('{}', { status: 200, headers }) as Response
|
|
339
|
+
const brokerProbe = async () => {
|
|
340
|
+
throw new Error('broker UDS connect failed')
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const result = await probeQuota(claudeDir, agentDir, directFetch, { brokerProbe })
|
|
344
|
+
expect(result.status).toBe('ok')
|
|
345
|
+
expect(result.detail).toContain('10% / 5h')
|
|
346
|
+
})
|
|
347
|
+
|
|
295
348
|
it('surfaces auth rejection with the RFC-H replace-account hint on 403', async () => {
|
|
296
349
|
const fakeFetch: typeof fetch = async () =>
|
|
297
350
|
new Response(null, { status: 403 }) as Response
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* via integration tests; here we keep it to pure unit coverage.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
10
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
11
11
|
import {
|
|
12
12
|
escapeHtmlForTg,
|
|
13
13
|
preBlock,
|
|
@@ -20,6 +20,28 @@ import {
|
|
|
20
20
|
} from '../shared/bot-runtime.js'
|
|
21
21
|
import type { Context } from 'grammy'
|
|
22
22
|
|
|
23
|
+
// ─── env hygiene ─────────────────────────────────────────────────────────
|
|
24
|
+
// The exec factories read SWITCHROOM_CONFIG and prepend `--config <path>` to
|
|
25
|
+
// argv. When the test runner inherits SWITCHROOM_CONFIG from the environment
|
|
26
|
+
// (e.g. switchroom-managed shells), this leaks into tests that use `echo`
|
|
27
|
+
// as the cliPath and breaks stdout assertions. Clear before each test and
|
|
28
|
+
// restore after.
|
|
29
|
+
|
|
30
|
+
let savedSwitchroomConfig: string | undefined
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
savedSwitchroomConfig = process.env.SWITCHROOM_CONFIG
|
|
34
|
+
delete process.env.SWITCHROOM_CONFIG
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
if (savedSwitchroomConfig !== undefined) {
|
|
39
|
+
process.env.SWITCHROOM_CONFIG = savedSwitchroomConfig
|
|
40
|
+
} else {
|
|
41
|
+
delete process.env.SWITCHROOM_CONFIG
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
23
45
|
// ─── escapeHtmlForTg ─────────────────────────────────────────────────────
|
|
24
46
|
|
|
25
47
|
describe('escapeHtmlForTg', () => {
|