switchroom 0.8.1 → 0.11.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 +54 -61
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +285 -45
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/drive-write-pretool.mjs +5418 -0
- package/dist/cli/switchroom.js +8890 -5560
- package/dist/host-control/main.js +582 -43
- package/dist/vault/approvals/kernel-server.js +276 -47
- package/dist/vault/broker/server.js +333 -69
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +3 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/default/CLAUDE.md +10 -0
- package/profiles/default/CLAUDE.md.hbs +16 -0
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +67 -15
- package/skills/switchroom-status/SKILL.md +26 -1
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +9 -5
- package/telegram-plugin/auth-snapshot-format.ts +612 -0
- package/telegram-plugin/auto-fallback-fleet.ts +215 -0
- package/telegram-plugin/auto-fallback.ts +28 -301
- package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
- package/telegram-plugin/fleet-fallback-gate.ts +105 -0
- package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
- package/telegram-plugin/gateway/approval-callback.ts +31 -3
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +905 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +23 -37
- package/telegram-plugin/gateway/boot-probes.ts +9 -12
- package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
- package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
- package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
- package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
- package/telegram-plugin/gateway/gateway.ts +1156 -938
- package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
- package/telegram-plugin/gateway/ipc-server.ts +69 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/model-unavailable.ts +28 -12
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +153 -1
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
- package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
- package/telegram-plugin/tests/boot-probes.test.ts +27 -22
- package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
- package/telegram-plugin/tests/silence-poke.test.ts +237 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
- package/telegram-plugin/turn-flush-safety.ts +55 -1
- package/telegram-plugin/uat/SETUP.md +35 -1
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/dist/foreman/foreman.js +0 -31358
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-card auth row formatter (RFC H §7.3).
|
|
3
|
+
*
|
|
4
|
+
* The old auth-dashboard exported `formatAccountQuotaLine` + an
|
|
5
|
+
* `AccountSummary` shape that the boot card consumed for its
|
|
6
|
+
* "Accounts (N)" section. Both source-of-truth and shape moved to
|
|
7
|
+
* the auth-broker's `list-state` response. This module reformats that
|
|
8
|
+
* response into the same one-line-per-account block the boot card
|
|
9
|
+
* used to render — visual output unchanged, data source is now the
|
|
10
|
+
* broker.
|
|
11
|
+
*
|
|
12
|
+
* Inputs: a `list-state` data shape (see
|
|
13
|
+
* `src/auth/broker/protocol.ts` → `ListStateDataSchema`) plus the
|
|
14
|
+
* caller agent's name.
|
|
15
|
+
*
|
|
16
|
+
* Output: an array of HTML-safe lines. Empty array when there's
|
|
17
|
+
* nothing to show — preserves the boot-card's silent-when-healthy
|
|
18
|
+
* default.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ListStateData, AccountState } from '../../src/auth/broker/client.js'
|
|
22
|
+
|
|
23
|
+
export type { ListStateData, AccountState }
|
|
24
|
+
|
|
25
|
+
// Local HTML-escape (mirrors the helper formerly co-located in
|
|
26
|
+
// auth-dashboard.ts so we keep the same escaping discipline without
|
|
27
|
+
// pulling in a heavier util).
|
|
28
|
+
function escapeHtml(s: string): string {
|
|
29
|
+
return s
|
|
30
|
+
.replace(/&/g, '&')
|
|
31
|
+
.replace(/</g, '<')
|
|
32
|
+
.replace(/>/g, '>')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Format a duration in ms as a short relative string ("1h 22m", "12s"). */
|
|
36
|
+
function formatRelativeMs(ms: number): string {
|
|
37
|
+
if (ms <= 0) return '0s'
|
|
38
|
+
const totalSec = Math.floor(ms / 1000)
|
|
39
|
+
const days = Math.floor(totalSec / 86400)
|
|
40
|
+
const hours = Math.floor((totalSec % 86400) / 3600)
|
|
41
|
+
const mins = Math.floor((totalSec % 3600) / 60)
|
|
42
|
+
const secs = totalSec % 60
|
|
43
|
+
if (days > 0) return `${days}d ${hours}h`
|
|
44
|
+
if (hours > 0) return `${hours}h ${mins}m`
|
|
45
|
+
if (mins > 0) return `${mins}m ${secs}s`
|
|
46
|
+
return `${secs}s`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render the per-account quota inline for one account row. Returns
|
|
51
|
+
* null when there's nothing quota-shaped to say (account is healthy
|
|
52
|
+
* and we have no reset countdown to surface).
|
|
53
|
+
*/
|
|
54
|
+
export function formatAuthQuotaLine(acc: AccountState, now: number = Date.now()): string | null {
|
|
55
|
+
if (acc.exhausted) {
|
|
56
|
+
const until = acc.exhausted_until
|
|
57
|
+
if (until != null && until > now) {
|
|
58
|
+
return `<i>exhausted · resets in ${formatRelativeMs(until - now)}</i>`
|
|
59
|
+
}
|
|
60
|
+
return `<i>exhausted</i>`
|
|
61
|
+
}
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Boot-card auth-row block.
|
|
67
|
+
*
|
|
68
|
+
* Strategy:
|
|
69
|
+
* 1. Determine *which* account is active for `agentName` (per-agent
|
|
70
|
+
* override wins over fleet-active).
|
|
71
|
+
* 2. Emit one row for that account marked with `▶` plus a
|
|
72
|
+
* best-effort quota suffix.
|
|
73
|
+
* 3. Emit one row per other account in `fallback_order` marked with
|
|
74
|
+
* `↳` so the operator sees the rollover plan at a glance.
|
|
75
|
+
*
|
|
76
|
+
* Returns an empty array when `state` is empty (no accounts) — the
|
|
77
|
+
* boot card's silent-when-healthy contract.
|
|
78
|
+
*/
|
|
79
|
+
export function renderAuthLine(
|
|
80
|
+
state: ListStateData,
|
|
81
|
+
agentName: string,
|
|
82
|
+
now: number = Date.now(),
|
|
83
|
+
): string[] {
|
|
84
|
+
if (!state || state.accounts.length === 0) return []
|
|
85
|
+
|
|
86
|
+
const agentEntry = state.agents.find((a) => a.name === agentName)
|
|
87
|
+
const activeLabel = agentEntry?.override ?? agentEntry?.account ?? state.active
|
|
88
|
+
|
|
89
|
+
// Stable display order: active first, then `fallback_order` minus
|
|
90
|
+
// the active label, then any remaining accounts (defensive — should
|
|
91
|
+
// be empty in steady state) in account-list order.
|
|
92
|
+
const seen = new Set<string>()
|
|
93
|
+
const order: string[] = []
|
|
94
|
+
if (activeLabel) {
|
|
95
|
+
order.push(activeLabel)
|
|
96
|
+
seen.add(activeLabel)
|
|
97
|
+
}
|
|
98
|
+
for (const label of state.fallback_order) {
|
|
99
|
+
if (!seen.has(label)) {
|
|
100
|
+
order.push(label)
|
|
101
|
+
seen.add(label)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const acc of state.accounts) {
|
|
105
|
+
if (!seen.has(acc.label)) {
|
|
106
|
+
order.push(acc.label)
|
|
107
|
+
seen.add(acc.label)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const byLabel = new Map(state.accounts.map((a) => [a.label, a]))
|
|
112
|
+
const rows: string[] = []
|
|
113
|
+
rows.push(`<b>Accounts (${state.accounts.length})</b>`)
|
|
114
|
+
for (const label of order) {
|
|
115
|
+
const acc = byLabel.get(label)
|
|
116
|
+
if (!acc) continue
|
|
117
|
+
const marker = label === activeLabel ? '▶' : '↳'
|
|
118
|
+
const labelHtml = `<code>${escapeHtml(acc.label)}</code>`
|
|
119
|
+
const quotaLine = formatAuthQuotaLine(acc, now)
|
|
120
|
+
rows.push(quotaLine ? `${marker} ${labelHtml} ${quotaLine}` : `${marker} ${labelHtml}`)
|
|
121
|
+
}
|
|
122
|
+
return rows
|
|
123
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapt the RFC H broker `auth show --json` / `auth list --json` payload
|
|
3
|
+
* (a `ListStateData`) into the per-agent `AuthSummary` shape the
|
|
4
|
+
* /status panel renders via `formatAuthLine`.
|
|
5
|
+
*
|
|
6
|
+
* Pre-RFC-H, gateway shelled out to `switchroom auth status --json`
|
|
7
|
+
* which already returned per-agent records in `AuthSummary` shape.
|
|
8
|
+
* That verb was retired; this adapter does the per-agent projection
|
|
9
|
+
* over the new fleet-broker payload.
|
|
10
|
+
*
|
|
11
|
+
* Pure & dependency-free so it can be unit-tested without a grammy
|
|
12
|
+
* Context or live broker.
|
|
13
|
+
*/
|
|
14
|
+
import type { AuthSummary } from '../welcome-text.js'
|
|
15
|
+
|
|
16
|
+
/** Mirrors `ListStateData` in src/auth/broker/client.ts — duplicated as
|
|
17
|
+
* a structural type so this adapter stays in the telegram-plugin
|
|
18
|
+
* workspace without importing across the src/ boundary. */
|
|
19
|
+
export interface BrokerStateView {
|
|
20
|
+
active: string
|
|
21
|
+
fallback_order: string[]
|
|
22
|
+
accounts: Array<{
|
|
23
|
+
label: string
|
|
24
|
+
expiresAt?: number
|
|
25
|
+
exhausted: boolean
|
|
26
|
+
}>
|
|
27
|
+
agents: Array<{
|
|
28
|
+
name: string
|
|
29
|
+
account: string
|
|
30
|
+
override: string | null
|
|
31
|
+
}>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Subset of `.claude.json` we need for billingType — duplicated for
|
|
35
|
+
* the same reason as BrokerStateView. */
|
|
36
|
+
export interface ClaudeJsonView {
|
|
37
|
+
oauthAccount?: {
|
|
38
|
+
billingType?: string
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatExpiresInRelative(expiresAt: number | undefined, now: number = Date.now()): string | null {
|
|
43
|
+
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return null
|
|
44
|
+
const delta = expiresAt - now
|
|
45
|
+
if (delta <= 0) return 'expired'
|
|
46
|
+
const days = Math.floor(delta / 86_400_000)
|
|
47
|
+
if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}`
|
|
48
|
+
const hours = Math.floor(delta / 3_600_000)
|
|
49
|
+
if (hours >= 1) return `in ${hours} hour${hours === 1 ? '' : 's'}`
|
|
50
|
+
const minutes = Math.max(1, Math.floor(delta / 60_000))
|
|
51
|
+
return `in ${minutes} minute${minutes === 1 ? '' : 's'}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mapBillingTypeToPlan(billingType: string | undefined): string | null {
|
|
55
|
+
if (!billingType) return null
|
|
56
|
+
const t = billingType.toLowerCase()
|
|
57
|
+
if (t.includes('max')) return 'Max'
|
|
58
|
+
if (t.includes('pro')) return 'Pro'
|
|
59
|
+
return billingType
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the per-agent AuthSummary from broker state.
|
|
64
|
+
*
|
|
65
|
+
* - `authenticated` = the agent is bound to an account that the broker
|
|
66
|
+
* knows about. Quota exhaustion is NOT counted as unauthenticated —
|
|
67
|
+
* the agent still has valid credentials, it just can't make calls
|
|
68
|
+
* until the broker rotates (which is a separate signal).
|
|
69
|
+
* - `auth_source` surfaces the bound account label (e.g. the email).
|
|
70
|
+
* Under RFC H all auth flows through the broker, so the source is
|
|
71
|
+
* "which account is currently mirrored to this agent", not the
|
|
72
|
+
* transport.
|
|
73
|
+
* - `subscription_type` is read from the agent's `.claude.json`
|
|
74
|
+
* because the broker doesn't track plan tier.
|
|
75
|
+
* - `expires_in` is computed from the bound account's `expiresAt`.
|
|
76
|
+
*/
|
|
77
|
+
export function buildAuthSummaryFromBroker(
|
|
78
|
+
state: BrokerStateView | null | undefined,
|
|
79
|
+
agentName: string,
|
|
80
|
+
claudeJson: ClaudeJsonView | null | undefined,
|
|
81
|
+
now: number = Date.now(),
|
|
82
|
+
): AuthSummary | null {
|
|
83
|
+
if (!state) return null
|
|
84
|
+
const binding = state.agents.find((a) => a.name === agentName)
|
|
85
|
+
if (!binding) {
|
|
86
|
+
return {
|
|
87
|
+
authenticated: false,
|
|
88
|
+
subscription_type: mapBillingTypeToPlan(claudeJson?.oauthAccount?.billingType),
|
|
89
|
+
expires_in: null,
|
|
90
|
+
auth_source: null,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const account = state.accounts.find((a) => a.label === binding.account)
|
|
94
|
+
const authenticated = account !== undefined
|
|
95
|
+
return {
|
|
96
|
+
authenticated,
|
|
97
|
+
subscription_type: mapBillingTypeToPlan(claudeJson?.oauthAccount?.billingType),
|
|
98
|
+
expires_in: account ? formatExpiresInRelative(account.expiresAt, now) : null,
|
|
99
|
+
auth_source: binding.account,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import type { ProbeResult, GatewayRuntimeInfo } from './boot-probes.js'
|
|
36
|
-
import type {
|
|
37
|
-
import {
|
|
36
|
+
import type { ListStateData } from './auth-line.js'
|
|
37
|
+
import { renderAuthLine } from './auth-line.js'
|
|
38
38
|
import {
|
|
39
39
|
probeAccount,
|
|
40
40
|
probeAgentProcess,
|
|
@@ -274,9 +274,12 @@ export interface RenderBootCardOpts {
|
|
|
274
274
|
* silent-when-healthy contract for callers that don't pass account
|
|
275
275
|
* data (tests, harnesses, gateways without the auth model).
|
|
276
276
|
*
|
|
277
|
-
*
|
|
277
|
+
* Post-RFC H (auth-broker rewire): this carries the broker's
|
|
278
|
+
* `list-state` shape — `renderAuthLine` consumes it to emit the
|
|
279
|
+
* same one-line-per-account rows as before. Callers that pass
|
|
280
|
+
* `null`/`undefined` get no section. Closes #708.
|
|
278
281
|
*/
|
|
279
|
-
accounts?:
|
|
282
|
+
accounts?: ListStateData | null
|
|
280
283
|
/** Probe keys for which the prior boot saw degraded/fail and this boot
|
|
281
284
|
* sees ok. Rendered as a small ✅ line above the degraded section so
|
|
282
285
|
* the user gets positive-feedback that a known issue is gone. */
|
|
@@ -380,11 +383,13 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
380
383
|
}
|
|
381
384
|
}
|
|
382
385
|
|
|
383
|
-
// Per-account
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
const accountRows =
|
|
386
|
+
// Per-account auth section (issue #708, RFC H rewire) — one line
|
|
387
|
+
// per known account with the active account marked. Renders
|
|
388
|
+
// alongside the ack line so users see headroom without running
|
|
389
|
+
// /auth or /usage. Source of truth: auth-broker list-state.
|
|
390
|
+
const accountRows = opts.accounts
|
|
391
|
+
? renderAuthLine(opts.accounts, agentName, (opts.now ?? new Date()).getTime())
|
|
392
|
+
: []
|
|
388
393
|
|
|
389
394
|
const sections: string[] = [ack]
|
|
390
395
|
if (degradedRows.length > 0) sections.push('', ...degradedRows)
|
|
@@ -394,31 +399,12 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
|
|
|
394
399
|
}
|
|
395
400
|
|
|
396
401
|
/**
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
* Reuses the dashboard's `formatAccountQuotaLine` so the two surfaces
|
|
402
|
-
* speak with one voice.
|
|
402
|
+
* Re-export the broker-fed auth-row renderer under its historical
|
|
403
|
+
* name so direct callers (tests, harnesses) keep working without
|
|
404
|
+
* importing two modules. New code should import `renderAuthLine`
|
|
405
|
+
* from `./auth-line.js` directly.
|
|
403
406
|
*/
|
|
404
|
-
export
|
|
405
|
-
accounts: ReadonlyArray<AccountSummary> | undefined,
|
|
406
|
-
now: Date,
|
|
407
|
-
): string[] {
|
|
408
|
-
if (!accounts || accounts.length === 0) return []
|
|
409
|
-
const rows: string[] = []
|
|
410
|
-
rows.push(`<b>Accounts (${accounts.length})</b>`)
|
|
411
|
-
const nowMs = now.getTime()
|
|
412
|
-
for (const a of accounts) {
|
|
413
|
-
const marker = a.activeForThisAgent ? '▶' : '↳'
|
|
414
|
-
const labelHtml = `<code>${escapeHtml(a.label)}</code>`
|
|
415
|
-
// formatAccountQuotaLine returns HTML (with <i> tags) so we don't
|
|
416
|
-
// re-escape — pass it through verbatim.
|
|
417
|
-
const quotaLine = formatAccountQuotaLine(a, nowMs)
|
|
418
|
-
rows.push(quotaLine ? `${marker} ${labelHtml} ${quotaLine}` : `${marker} ${labelHtml}`)
|
|
419
|
-
}
|
|
420
|
-
return rows
|
|
421
|
-
}
|
|
407
|
+
export { renderAuthLine as renderAccountRows } from './auth-line.js'
|
|
422
408
|
|
|
423
409
|
// ─── Probe orchestration ─────────────────────────────────────────────────────
|
|
424
410
|
|
|
@@ -480,9 +466,9 @@ export interface RunProbesOpts {
|
|
|
480
466
|
* during the post-settle re-render so the first paint stays fast.
|
|
481
467
|
*/
|
|
482
468
|
loadAccounts?: () =>
|
|
483
|
-
|
|
|
469
|
+
| ListStateData
|
|
484
470
|
| null
|
|
485
|
-
| Promise<
|
|
471
|
+
| Promise<ListStateData | null>
|
|
486
472
|
/** When true, resolve the agent PID via cgroup walk instead of MainPID
|
|
487
473
|
* (which is the tmux server pid under tmux supervisor). */
|
|
488
474
|
tmuxSupervisor?: boolean
|
|
@@ -513,7 +499,7 @@ export async function runAllProbes(opts: RunProbesOpts): Promise<ProbeMap> {
|
|
|
513
499
|
const slug = opts.agentSlug ?? opts.agentName
|
|
514
500
|
|
|
515
501
|
await Promise.allSettled([
|
|
516
|
-
probeAccount(opts.agentDir
|
|
502
|
+
probeAccount(opts.agentDir).then(r => { probes.account = r }),
|
|
517
503
|
probeAgentProcess(slug, { execFileImpl: opts.probeExecFileImpl, tmuxSupervisor: opts.tmuxSupervisor, dockerMode: opts.dockerMode }).then(r => { probes.agent = r }),
|
|
518
504
|
probeGateway(opts.gatewayInfo).then(r => { probes.gateway = r }),
|
|
519
505
|
probeQuota(claudeDir, opts.agentDir, opts.fetchImpl).then(r => { probes.quota = r }),
|
|
@@ -604,7 +590,7 @@ export async function startBootCard(
|
|
|
604
590
|
// Per-account rows (issue #708). Loaded best-effort
|
|
605
591
|
// alongside probes; failures are swallowed so the card still
|
|
606
592
|
// renders correctly with no accounts section.
|
|
607
|
-
let accountRows:
|
|
593
|
+
let accountRows: ListStateData | null = null
|
|
608
594
|
if (opts.loadAccounts) {
|
|
609
595
|
try {
|
|
610
596
|
accountRows = await opts.loadAccounts()
|
|
@@ -121,16 +121,10 @@ const TOKEN_EXPIRING_SOON_DAYS = 7
|
|
|
121
121
|
*/
|
|
122
122
|
export async function probeAccount(
|
|
123
123
|
agentDir: string,
|
|
124
|
-
opts: { agentName?: string } = {},
|
|
125
124
|
): Promise<ProbeResult> {
|
|
126
125
|
return withTimeout('Account', (async (): Promise<ProbeResult> => {
|
|
127
126
|
const claudeDir = join(agentDir, '.claude')
|
|
128
127
|
const claudeJsonPath = join(claudeDir, '.claude.json')
|
|
129
|
-
// Fall back to the literal placeholder only when no agentName is plumbed
|
|
130
|
-
// through — the renderer's <code> escape will keep that safe in Telegram
|
|
131
|
-
// HTML, but real call sites should always pass the name so users can
|
|
132
|
-
// tap-to-copy a working command.
|
|
133
|
-
const agentRef = opts.agentName ?? '<agent>'
|
|
134
128
|
let cfg: ClaudeJson = {}
|
|
135
129
|
try {
|
|
136
130
|
const raw = readFileSync(claudeJsonPath, 'utf8')
|
|
@@ -145,7 +139,10 @@ export async function probeAccount(
|
|
|
145
139
|
status: 'degraded',
|
|
146
140
|
label: 'Account',
|
|
147
141
|
detail: 'not signed in',
|
|
148
|
-
|
|
142
|
+
// RFC H: auth is fleet-wide. Recovery is `auth add` + `auth use` —
|
|
143
|
+
// the broker then fans the active label out to every agent. There
|
|
144
|
+
// is no per-agent `auth login` verb anymore.
|
|
145
|
+
nextStep: 'Run `switchroom auth add <label> --from-oauth` then `switchroom auth use <label>` to authenticate the fleet',
|
|
149
146
|
}
|
|
150
147
|
}
|
|
151
148
|
|
|
@@ -176,9 +173,9 @@ export async function probeAccount(
|
|
|
176
173
|
}
|
|
177
174
|
|
|
178
175
|
const nextStep = status === 'fail'
|
|
179
|
-
?
|
|
176
|
+
? 'OAuth token expired — broker should auto-refresh; force with `switchroom auth refresh` or `switchroom auth add <label> --from-oauth --replace` if the refresh token is also bad'
|
|
180
177
|
: status === 'degraded'
|
|
181
|
-
?
|
|
178
|
+
? 'Token expiring soon — broker auto-refreshes < 60min before expiry; force now with `switchroom auth refresh`'
|
|
182
179
|
: undefined
|
|
183
180
|
return {
|
|
184
181
|
status,
|
|
@@ -861,7 +858,7 @@ export async function probeQuota(
|
|
|
861
858
|
status: 'degraded',
|
|
862
859
|
label: 'Quota',
|
|
863
860
|
detail: 'no OAuth token',
|
|
864
|
-
nextStep: 'No OAuth token on disk —
|
|
861
|
+
nextStep: 'No OAuth token on disk — register a fleet account: `switchroom auth add <label> --from-oauth` then `switchroom auth use <label>` (RFC H)',
|
|
865
862
|
}
|
|
866
863
|
}
|
|
867
864
|
|
|
@@ -880,8 +877,8 @@ export async function probeQuota(
|
|
|
880
877
|
label: 'Quota',
|
|
881
878
|
detail: probe.reason,
|
|
882
879
|
nextStep: isAuth
|
|
883
|
-
? 'Auth rejected by Anthropic —
|
|
884
|
-
: 'Anthropic quota probe failed — re-check after a minute;
|
|
880
|
+
? 'Auth rejected by Anthropic — broker auto-refreshes; if persistent, replace the account: `switchroom auth add <label> --from-oauth --replace`'
|
|
881
|
+
: 'Anthropic quota probe failed — re-check after a minute; broker auto-rotates per `auth.fallback_order`',
|
|
885
882
|
}
|
|
886
883
|
}
|
|
887
884
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Telegram diff-preview card renderer — RFC E §4.2.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { InlineKeyboard } from "grammy";
|
|
7
|
+
|
|
8
|
+
import { buildDiffPreview } from "../../src/drive/diff-preview.js";
|
|
9
|
+
import type { DiffPreviewInput } from "../../src/drive/diff-preview.js";
|
|
10
|
+
import { buildDiffPreviewCard } from "./diff-preview-card.js";
|
|
11
|
+
|
|
12
|
+
/** Pull row-major button shape out of grammy's InlineKeyboard. */
|
|
13
|
+
function rows(kb: InlineKeyboard): Array<Array<{ text: string; callback_data?: string; url?: string }>> {
|
|
14
|
+
return kb.inline_keyboard.map((row) =>
|
|
15
|
+
row.map((b) => ({
|
|
16
|
+
text: b.text,
|
|
17
|
+
...("callback_data" in b ? { callback_data: b.callback_data } : {}),
|
|
18
|
+
...("url" in b ? { url: b.url } : {}),
|
|
19
|
+
})),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function baseInput(overrides: Partial<DiffPreviewInput> = {}): DiffPreviewInput {
|
|
24
|
+
return {
|
|
25
|
+
agentName: "klanker",
|
|
26
|
+
docTitle: "Q3 Strategy Notes",
|
|
27
|
+
fileId: "DOC1",
|
|
28
|
+
mimeType: "application/vnd.google-apps.document",
|
|
29
|
+
resolvedAnchor: {
|
|
30
|
+
op: { kind: "insert_after", paragraphIndex: 4 },
|
|
31
|
+
displayName: "after heading 'Goals' (level 2)",
|
|
32
|
+
},
|
|
33
|
+
metrics: { linesAdded: 47, linesRemoved: 0 },
|
|
34
|
+
mode: "suggest",
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("buildDiffPreviewCard — suggest mode (default)", () => {
|
|
40
|
+
it("emits the wrapper-attested body + all four buttons in the RFC layout", () => {
|
|
41
|
+
const preview = buildDiffPreview(baseInput({ agentSummary: "Added Hiring section" }));
|
|
42
|
+
const card = buildDiffPreviewCard({
|
|
43
|
+
preview,
|
|
44
|
+
suggestRequestId: "aabbccdd",
|
|
45
|
+
writeRequestId: "11223344",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Body: title bold + all preview lines.
|
|
49
|
+
expect(card.text).toContain("<b>");
|
|
50
|
+
expect(card.text).toContain("klanker");
|
|
51
|
+
expect(card.text).toContain("Q3 Strategy Notes");
|
|
52
|
+
expect(card.text).toContain("📍 after heading 'Goals' (level 2)");
|
|
53
|
+
expect(card.text).toContain("+47");
|
|
54
|
+
expect(card.text).toContain("💬");
|
|
55
|
+
expect(card.text).toContain("Added Hiring section");
|
|
56
|
+
|
|
57
|
+
const r = rows(card.reply_markup);
|
|
58
|
+
// Row 1: [Open in Drive] [Apply as suggestion]
|
|
59
|
+
expect(r[0]?.[0]?.text).toBe("📖 Open in Drive");
|
|
60
|
+
expect(r[0]?.[0]?.url).toBe("https://docs.google.com/document/d/DOC1/edit");
|
|
61
|
+
expect(r[0]?.[1]?.text).toBe("✅ Apply as suggestion");
|
|
62
|
+
expect(r[0]?.[1]?.callback_data).toBe("apv:aabbccdd:once");
|
|
63
|
+
// Row 2: [Apply directly] [Cancel]
|
|
64
|
+
expect(r[1]?.[0]?.text).toBe("⚠ Apply directly");
|
|
65
|
+
expect(r[1]?.[0]?.callback_data).toBe("apv:11223344:once");
|
|
66
|
+
expect(r[1]?.[1]?.text).toBe("🚫 Cancel");
|
|
67
|
+
expect(r[1]?.[1]?.callback_data).toBe("apv:aabbccdd:deny");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("hides 'Apply directly' when writeRequestId is undefined", () => {
|
|
71
|
+
const preview = buildDiffPreview(baseInput());
|
|
72
|
+
const card = buildDiffPreviewCard({
|
|
73
|
+
preview,
|
|
74
|
+
suggestRequestId: "aabbccdd",
|
|
75
|
+
});
|
|
76
|
+
const flat = rows(card.reply_markup).flat();
|
|
77
|
+
expect(flat.find((b) => b.text === "⚠ Apply directly")).toBeUndefined();
|
|
78
|
+
// The other three buttons still present.
|
|
79
|
+
expect(flat.find((b) => b.text === "📖 Open in Drive")).toBeDefined();
|
|
80
|
+
expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeDefined();
|
|
81
|
+
expect(flat.find((b) => b.text === "🚫 Cancel")).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("buildDiffPreviewCard — write mode (opt-in via expand)", () => {
|
|
86
|
+
it("only emits Apply-directly + Open-in-Drive + Cancel (no suggest button)", () => {
|
|
87
|
+
const preview = buildDiffPreview(baseInput({ mode: "write" }));
|
|
88
|
+
const card = buildDiffPreviewCard({
|
|
89
|
+
preview,
|
|
90
|
+
// In write-mode the suggest id is still needed for the Cancel
|
|
91
|
+
// callback's deny channel — semantically Cancel is "don't grant
|
|
92
|
+
// either scope" but reusing the suggest id keeps the existing
|
|
93
|
+
// approval-callback handler stateless.
|
|
94
|
+
suggestRequestId: "aabbccdd",
|
|
95
|
+
writeRequestId: "11223344",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const r = rows(card.reply_markup);
|
|
99
|
+
const flat = r.flat();
|
|
100
|
+
expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeUndefined();
|
|
101
|
+
const directly = flat.find((b) => b.text === "⚠ Apply directly");
|
|
102
|
+
expect(directly).toBeDefined();
|
|
103
|
+
expect(directly?.callback_data).toBe("apv:11223344:once");
|
|
104
|
+
// Title icon swaps to ⚠.
|
|
105
|
+
expect(card.text).toContain("⚠");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("buildDiffPreviewCard — input validation", () => {
|
|
110
|
+
it("throws on a malformed suggestRequestId", () => {
|
|
111
|
+
const preview = buildDiffPreview(baseInput());
|
|
112
|
+
expect(() =>
|
|
113
|
+
buildDiffPreviewCard({ preview, suggestRequestId: "not-hex" }),
|
|
114
|
+
).toThrow(/8 hex chars/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("throws on a malformed writeRequestId", () => {
|
|
118
|
+
const preview = buildDiffPreview(baseInput());
|
|
119
|
+
expect(() =>
|
|
120
|
+
buildDiffPreviewCard({
|
|
121
|
+
preview,
|
|
122
|
+
suggestRequestId: "aabbccdd",
|
|
123
|
+
writeRequestId: "ABCDEF01", // wrong case
|
|
124
|
+
}),
|
|
125
|
+
).toThrow(/8 hex chars/);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("buildDiffPreviewCard — fragility guards", () => {
|
|
130
|
+
it("drops the Open-in-Drive button when fileId is the 'pending-create' sentinel", () => {
|
|
131
|
+
// create_doc prep emits "pending-create" as a placeholder fileId
|
|
132
|
+
// (the doc doesn't exist yet). The renderer must NOT emit a Drive
|
|
133
|
+
// URL pointing at a nonexistent doc.
|
|
134
|
+
const preview = buildDiffPreview(
|
|
135
|
+
baseInput({ fileId: "pending-create" }),
|
|
136
|
+
);
|
|
137
|
+
const card = buildDiffPreviewCard({
|
|
138
|
+
preview,
|
|
139
|
+
suggestRequestId: "aabbccdd",
|
|
140
|
+
});
|
|
141
|
+
const flat = rows(card.reply_markup).flat();
|
|
142
|
+
expect(flat.find((b) => b.text === "📖 Open in Drive")).toBeUndefined();
|
|
143
|
+
// Apply buttons still present — the doc creation flow is still actionable.
|
|
144
|
+
expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("HTML-escapes title + lines (no markup injection from doc names)", () => {
|
|
148
|
+
const preview = buildDiffPreview(
|
|
149
|
+
baseInput({ docTitle: "<script>alert(1)</script>" }),
|
|
150
|
+
);
|
|
151
|
+
const card = buildDiffPreviewCard({
|
|
152
|
+
preview,
|
|
153
|
+
suggestRequestId: "aabbccdd",
|
|
154
|
+
});
|
|
155
|
+
expect(card.text).not.toContain("<script>");
|
|
156
|
+
expect(card.text).toContain("<script>");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("HTML-escapes the agent-supplied summary", () => {
|
|
160
|
+
const preview = buildDiffPreview(
|
|
161
|
+
baseInput({ agentSummary: "Hi <b>bold</b> & <i>tags</i>" }),
|
|
162
|
+
);
|
|
163
|
+
const card = buildDiffPreviewCard({
|
|
164
|
+
preview,
|
|
165
|
+
suggestRequestId: "aabbccdd",
|
|
166
|
+
});
|
|
167
|
+
expect(card.text).not.toMatch(/💬.*<b>/);
|
|
168
|
+
expect(card.text).toContain("<b>");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("buildDiffPreviewCard — audit fidelity", () => {
|
|
173
|
+
it("preview audit row matches what the user sees on the card", () => {
|
|
174
|
+
const input = baseInput({ agentSummary: "Added the Hiring section" });
|
|
175
|
+
const preview = buildDiffPreview(input);
|
|
176
|
+
const card = buildDiffPreviewCard({
|
|
177
|
+
preview,
|
|
178
|
+
suggestRequestId: "aabbccdd",
|
|
179
|
+
writeRequestId: "11223344",
|
|
180
|
+
});
|
|
181
|
+
// The audit row captures both wrapper truth + agent framing,
|
|
182
|
+
// exactly as surfaced on the card.
|
|
183
|
+
expect(preview.audit.wrapperAttested.anchorDisplayName).toBe(
|
|
184
|
+
"after heading 'Goals' (level 2)",
|
|
185
|
+
);
|
|
186
|
+
expect(preview.audit.wrapperAttested.linesAdded).toBe(47);
|
|
187
|
+
expect(preview.audit.agentSupplied.summary).toBe("Added the Hiring section");
|
|
188
|
+
// Card body contains both.
|
|
189
|
+
expect(card.text).toContain("after heading 'Goals' (level 2)");
|
|
190
|
+
expect(card.text).toContain("Added the Hiring section");
|
|
191
|
+
});
|
|
192
|
+
});
|