switchroom 0.8.1 → 0.10.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 +49 -57
- 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/switchroom.js +15931 -12778
- 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/index.ts +7 -5
- package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
- 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 +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +22 -36
- package/telegram-plugin/gateway/boot-probes.ts +3 -3
- package/telegram-plugin/gateway/gateway.ts +313 -798
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- 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/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-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-probes.test.ts +11 -4
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/uat/SETUP.md +31 -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/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/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
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure create-agent flow state machine — extracted from foreman.ts for
|
|
3
|
-
* testability. No grammY imports, no SQLite imports, no side effects.
|
|
4
|
-
*
|
|
5
|
-
* Each function takes current state + input and returns an Action
|
|
6
|
-
* (what the foreman should do next). foreman.ts interprets actions
|
|
7
|
-
* by calling the actual SQLite / grammY / orchestrator APIs.
|
|
8
|
-
*
|
|
9
|
-
* Steps:
|
|
10
|
-
* start → asked-name (when no name given)
|
|
11
|
-
* → asked-profile (when name provided inline)
|
|
12
|
-
* asked-name + text → asked-profile (if valid name)
|
|
13
|
-
* asked-profile + text → asked-bot-token (if valid profile)
|
|
14
|
-
* asked-bot-token + text → asked-oauth-code (after createAgent())
|
|
15
|
-
* asked-oauth-code + text → done (after completeCreation())
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import type { CreateFlowState, CreateFlowStep } from './state.js'
|
|
19
|
-
|
|
20
|
-
// ─── Action types ────────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
export type CreateFlowAction =
|
|
23
|
-
| { kind: 'ask-name' }
|
|
24
|
-
| { kind: 'ask-profile'; profiles: string[] }
|
|
25
|
-
| { kind: 'ask-bot-token'; name: string; profile: string }
|
|
26
|
-
| { kind: 'call-create-agent'; name: string; profile: string; botToken: string }
|
|
27
|
-
| { kind: 'ask-oauth-code'; loginUrl: string; name: string }
|
|
28
|
-
| { kind: 'call-complete-creation'; name: string; code: string }
|
|
29
|
-
| { kind: 'done'; name: string; botUsername: string | null }
|
|
30
|
-
| { kind: 'error'; message: string; stayInStep: boolean }
|
|
31
|
-
| { kind: 'cancel'; reason: string }
|
|
32
|
-
|
|
33
|
-
// ─── Name validation (mirrors assertSafeAgentName) ───────────────────────
|
|
34
|
-
|
|
35
|
-
export function isValidAgentName(name: string): boolean {
|
|
36
|
-
return /^[a-z0-9][a-z0-9_-]{0,50}$/.test(name)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ─── Flow entry point ────────────────────────────────────────────────────
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Start or resume a /create-agent flow.
|
|
43
|
-
*
|
|
44
|
-
* @param inlineName Optional name from the command args (/create-agent gymbro)
|
|
45
|
-
* @param profiles Available profile names (from listAvailableProfiles())
|
|
46
|
-
* @returns Action to perform
|
|
47
|
-
*/
|
|
48
|
-
export function startCreateFlow(
|
|
49
|
-
inlineName: string | null,
|
|
50
|
-
profiles: string[],
|
|
51
|
-
): CreateFlowAction {
|
|
52
|
-
if (!inlineName) {
|
|
53
|
-
return { kind: 'ask-name' }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (!isValidAgentName(inlineName)) {
|
|
57
|
-
return {
|
|
58
|
-
kind: 'error',
|
|
59
|
-
message: `"${inlineName}" is not a valid agent name. Names must be lowercase, alphanumeric, hyphens or underscores, max 51 chars.`,
|
|
60
|
-
stayInStep: false,
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return { kind: 'ask-profile', profiles }
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ─── Step transition: handle inbound text for current step ───────────────
|
|
68
|
-
|
|
69
|
-
export interface StepTransitionInput {
|
|
70
|
-
/** Current persisted state (or null if no state yet). */
|
|
71
|
-
state: CreateFlowState | null
|
|
72
|
-
/** The text the user sent. */
|
|
73
|
-
text: string
|
|
74
|
-
/** Available profiles (for profile validation). */
|
|
75
|
-
profiles: string[]
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Given the current state and user text, compute the next action.
|
|
80
|
-
* The caller (foreman.ts) is responsible for persisting state changes
|
|
81
|
-
* and executing the returned action.
|
|
82
|
-
*/
|
|
83
|
-
export function handleFlowText(input: StepTransitionInput): CreateFlowAction {
|
|
84
|
-
const { state, text, profiles } = input
|
|
85
|
-
const trimmed = text.trim()
|
|
86
|
-
|
|
87
|
-
if (!state) {
|
|
88
|
-
// No active flow — ignore
|
|
89
|
-
return { kind: 'cancel', reason: 'no-active-flow' }
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
switch (state.step) {
|
|
93
|
-
case 'asked-name': {
|
|
94
|
-
if (!isValidAgentName(trimmed)) {
|
|
95
|
-
return {
|
|
96
|
-
kind: 'error',
|
|
97
|
-
message: `"${trimmed}" is not a valid agent name. Names must be lowercase, alphanumeric, hyphens or underscores, max 51 chars. Try again:`,
|
|
98
|
-
stayInStep: true,
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return { kind: 'ask-profile', profiles }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
case 'asked-profile': {
|
|
105
|
-
if (!profiles.includes(trimmed)) {
|
|
106
|
-
return {
|
|
107
|
-
kind: 'error',
|
|
108
|
-
message: `Unknown profile "${trimmed}". Choose one of: ${profiles.join(', ')}`,
|
|
109
|
-
stayInStep: true,
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
// Pre-#28 fix this fell back to `trimmed` (the profile name)
|
|
113
|
-
// when state.name was missing — silently treating the profile
|
|
114
|
-
// as the agent name. Now we cancel with missing-name instead,
|
|
115
|
-
// matching the asked-bot-token step's behaviour on corrupt
|
|
116
|
-
// state. The fallback wasn't exploitable (assertSafeAgentName
|
|
117
|
-
// catches it downstream), but it's semantically wrong.
|
|
118
|
-
if (!state.name) {
|
|
119
|
-
return { kind: 'cancel', reason: 'missing-name' }
|
|
120
|
-
}
|
|
121
|
-
return { kind: 'ask-bot-token', name: state.name, profile: trimmed }
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
case 'asked-bot-token': {
|
|
125
|
-
const name = state.name ?? ''
|
|
126
|
-
const profile = state.profile ?? ''
|
|
127
|
-
if (!name || !profile) {
|
|
128
|
-
return { kind: 'cancel', reason: 'missing-name-or-profile' }
|
|
129
|
-
}
|
|
130
|
-
// Basic bot token shape check (foreman.ts validates via Telegram API)
|
|
131
|
-
if (!trimmed.includes(':') || trimmed.length < 20) {
|
|
132
|
-
return {
|
|
133
|
-
kind: 'error',
|
|
134
|
-
message: "That doesn't look like a BotFather token. It should be in the form <code>1234567890:AAH...</code> — try again:",
|
|
135
|
-
stayInStep: true,
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return { kind: 'call-create-agent', name, profile, botToken: trimmed }
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
case 'asked-oauth-code': {
|
|
142
|
-
const name = state.name ?? ''
|
|
143
|
-
if (!name) return { kind: 'cancel', reason: 'missing-name' }
|
|
144
|
-
// Codes are typically 8+ alphanumeric chars; pass through for server validation
|
|
145
|
-
if (trimmed.length < 4) {
|
|
146
|
-
return {
|
|
147
|
-
kind: 'error',
|
|
148
|
-
message: 'That code looks too short. Paste the full code from the browser:',
|
|
149
|
-
stayInStep: true,
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return { kind: 'call-complete-creation', name, code: trimmed }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
case 'done':
|
|
156
|
-
return { kind: 'cancel', reason: 'flow-already-done' }
|
|
157
|
-
|
|
158
|
-
default: {
|
|
159
|
-
const _exhaustive: never = state.step
|
|
160
|
-
return { kind: 'cancel', reason: `unknown-step:${_exhaustive}` }
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ─── State factory helpers (for foreman.ts to build new state objects) ───
|
|
166
|
-
|
|
167
|
-
export function makeInitialState(chatId: string, name: string | null): CreateFlowState {
|
|
168
|
-
const now = Date.now()
|
|
169
|
-
return {
|
|
170
|
-
chatId,
|
|
171
|
-
step: name ? 'asked-profile' : 'asked-name',
|
|
172
|
-
name,
|
|
173
|
-
profile: null,
|
|
174
|
-
botToken: null,
|
|
175
|
-
authSessionName: null,
|
|
176
|
-
loginUrl: null,
|
|
177
|
-
startedAt: now,
|
|
178
|
-
updatedAt: now,
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function advanceState(
|
|
183
|
-
state: CreateFlowState,
|
|
184
|
-
updates: Partial<Omit<CreateFlowState, 'chatId' | 'startedAt'>>,
|
|
185
|
-
): CreateFlowState {
|
|
186
|
-
return {
|
|
187
|
-
...state,
|
|
188
|
-
...updates,
|
|
189
|
-
updatedAt: Date.now(),
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Compute the human-readable step label for recovery messages. */
|
|
194
|
-
export function stepLabel(step: CreateFlowStep): string {
|
|
195
|
-
switch (step) {
|
|
196
|
-
case 'asked-name': return 'waiting for agent name'
|
|
197
|
-
case 'asked-profile': return 'waiting for profile selection'
|
|
198
|
-
case 'asked-bot-token': return 'waiting for BotFather token'
|
|
199
|
-
case 'asked-oauth-code': return 'waiting for OAuth code'
|
|
200
|
-
case 'done': return 'done'
|
|
201
|
-
}
|
|
202
|
-
}
|
|
@@ -1,493 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure handler logic extracted from foreman.ts for testability.
|
|
3
|
-
*
|
|
4
|
-
* foreman.ts has process-level side effects (reads .env, connects Bot,
|
|
5
|
-
* starts polling) that prevent direct import in tests. This module
|
|
6
|
-
* exports the command handler implementations and their helpers so that
|
|
7
|
-
* tests can exercise real code with mocked bot + ctx, rather than
|
|
8
|
-
* re-implementing the logic locally.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { execFileSync } from 'child_process'
|
|
12
|
-
import { renameSync, existsSync } from 'fs'
|
|
13
|
-
import { homedir } from 'os'
|
|
14
|
-
import { join, resolve } from 'path'
|
|
15
|
-
import {
|
|
16
|
-
escapeHtmlForTg,
|
|
17
|
-
preBlock,
|
|
18
|
-
stripAnsi,
|
|
19
|
-
formatSwitchroomOutput,
|
|
20
|
-
} from '../shared/bot-runtime.js'
|
|
21
|
-
|
|
22
|
-
// ─── Types ────────────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
export type SwitchroomExecFn = (args: string[]) => string
|
|
25
|
-
export type SwitchroomExecJsonFn = <T = unknown>(args: string[]) => T | null
|
|
26
|
-
|
|
27
|
-
// ─── Agent name validation ────────────────────────────────────────────────
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Throw if the agent name is not safe for use in journalctl unit names.
|
|
31
|
-
* Mirrors AGENT_NAME_RE in src/agents/create-orchestrator.ts and the yaml
|
|
32
|
-
* schema in src/config/schema.ts — all three MUST stay in sync. Max 51
|
|
33
|
-
* chars (see operator-events.ts callback_data contract).
|
|
34
|
-
*/
|
|
35
|
-
export function assertSafeAgentName(name: string): void {
|
|
36
|
-
if (!/^[a-z0-9][a-z0-9_-]{0,50}$/.test(name)) {
|
|
37
|
-
throw new Error(`invalid agent name: ${name}`)
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// ─── Tail-N parsing ───────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
export function parseTailN(args: string[]): number {
|
|
44
|
-
let tailN = 50
|
|
45
|
-
const tailIdx = args.indexOf('--tail')
|
|
46
|
-
if (tailIdx !== -1 && args[tailIdx + 1]) {
|
|
47
|
-
const parsed = parseInt(args[tailIdx + 1], 10)
|
|
48
|
-
if (!isNaN(parsed) && parsed > 0) tailN = Math.min(parsed, 500)
|
|
49
|
-
}
|
|
50
|
-
return tailN
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ─── Text chunking ────────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
export function chunkText(text: string, maxLen = 4096): string[] {
|
|
56
|
-
if (text.length <= maxLen) return [text]
|
|
57
|
-
const chunks: string[] = []
|
|
58
|
-
let pos = 0
|
|
59
|
-
while (pos < text.length) {
|
|
60
|
-
chunks.push(text.slice(pos, pos + maxLen))
|
|
61
|
-
pos += maxLen
|
|
62
|
-
}
|
|
63
|
-
return chunks
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ─── /status handler impl ─────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
export type AgentListEntry = {
|
|
69
|
-
name: string
|
|
70
|
-
status: string
|
|
71
|
-
uptime: string
|
|
72
|
-
template?: string | null
|
|
73
|
-
topic_name?: string | null
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function statusIcon(status: string): string {
|
|
77
|
-
if (status === 'active' || status === 'running') return '🟢'
|
|
78
|
-
if (status === 'inactive' || status === 'stopped' || status === 'dead') return '🔴'
|
|
79
|
-
if (status === 'failed') return '⚠️'
|
|
80
|
-
return '⚪'
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function buildFleetSummary(
|
|
84
|
-
switchroomExecJson: SwitchroomExecJsonFn,
|
|
85
|
-
): string {
|
|
86
|
-
try {
|
|
87
|
-
const data = switchroomExecJson<{ agents: AgentListEntry[] }>(['agent', 'list'])
|
|
88
|
-
if (!data || data.agents.length === 0) return '<i>No agents defined</i>'
|
|
89
|
-
const lines = ['<b>Fleet status</b>']
|
|
90
|
-
for (const a of data.agents) {
|
|
91
|
-
lines.push(
|
|
92
|
-
`${statusIcon(a.status)} <b>${escapeHtmlForTg(a.name)}</b> · ${escapeHtmlForTg(a.status)} · ${escapeHtmlForTg(a.uptime)}`,
|
|
93
|
-
)
|
|
94
|
-
if (a.template || a.topic_name) {
|
|
95
|
-
const meta = [a.template, a.topic_name]
|
|
96
|
-
.filter(Boolean)
|
|
97
|
-
.map((s) => escapeHtmlForTg(s!))
|
|
98
|
-
.join(' → ')
|
|
99
|
-
lines.push(` <i>${meta}</i>`)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return lines.join('\n')
|
|
103
|
-
} catch (err) {
|
|
104
|
-
return `<b>agent list failed:</b>\n${preBlock(formatSwitchroomOutput((err as Error).message))}`
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ─── /logs handler impl ───────────────────────────────────────────────────
|
|
109
|
-
|
|
110
|
-
export const LOG_PAGE_BYTES = 3 * 1024 // 3 KB
|
|
111
|
-
|
|
112
|
-
export interface LogsResult {
|
|
113
|
-
/** One or more reply strings. Send them in order. */
|
|
114
|
-
replies: Array<{ text: string; html: boolean }>
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Core /logs implementation — returns the reply payloads rather than
|
|
119
|
-
* sending them directly, so the caller (foreman.ts) can use its own
|
|
120
|
-
* switchroomReply and tests can inspect the output.
|
|
121
|
-
*
|
|
122
|
-
* @param match The text after "/logs " from ctx.match
|
|
123
|
-
* @param execFile Injected execFileSync for testability
|
|
124
|
-
*/
|
|
125
|
-
export function handleLogsCommand(
|
|
126
|
-
match: string,
|
|
127
|
-
execFile: typeof execFileSync = execFileSync,
|
|
128
|
-
): LogsResult {
|
|
129
|
-
const args = match.trim().split(/\s+/).filter(Boolean)
|
|
130
|
-
|
|
131
|
-
if (args.length === 0) {
|
|
132
|
-
return { replies: [{ text: 'Usage: /logs <agent> [--tail N]', html: true }] }
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const agentName = args[0]
|
|
136
|
-
try {
|
|
137
|
-
assertSafeAgentName(agentName)
|
|
138
|
-
} catch {
|
|
139
|
-
return { replies: [{ text: 'Invalid agent name.', html: true }] }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const tailN = parseTailN(args)
|
|
143
|
-
|
|
144
|
-
let output: string
|
|
145
|
-
try {
|
|
146
|
-
output = stripAnsi(
|
|
147
|
-
execFile(
|
|
148
|
-
'journalctl',
|
|
149
|
-
[
|
|
150
|
-
'--user',
|
|
151
|
-
'-u',
|
|
152
|
-
`switchroom-${agentName}`,
|
|
153
|
-
'-n',
|
|
154
|
-
String(tailN),
|
|
155
|
-
'--no-pager',
|
|
156
|
-
'--output=short-monotonic',
|
|
157
|
-
],
|
|
158
|
-
{
|
|
159
|
-
encoding: 'utf-8',
|
|
160
|
-
timeout: 10000,
|
|
161
|
-
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
|
|
162
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
163
|
-
},
|
|
164
|
-
) as string,
|
|
165
|
-
)
|
|
166
|
-
} catch (err) {
|
|
167
|
-
const msg = err as { stdout?: string; stderr?: string; message?: string }
|
|
168
|
-
const detail = msg.stdout || msg.stderr || msg.message || 'unknown error'
|
|
169
|
-
return {
|
|
170
|
-
replies: [
|
|
171
|
-
{
|
|
172
|
-
text: `<b>logs failed for ${escapeHtmlForTg(agentName)}:</b>\n${preBlock(formatSwitchroomOutput(stripAnsi(detail)))}`,
|
|
173
|
-
html: true,
|
|
174
|
-
},
|
|
175
|
-
],
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const trimmed = output.trim()
|
|
180
|
-
if (!trimmed) {
|
|
181
|
-
return {
|
|
182
|
-
replies: [
|
|
183
|
-
{
|
|
184
|
-
text: `No logs found for <code>${escapeHtmlForTg(agentName)}</code>.`,
|
|
185
|
-
html: true,
|
|
186
|
-
},
|
|
187
|
-
],
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (Buffer.byteLength(trimmed, 'utf8') > LOG_PAGE_BYTES) {
|
|
192
|
-
const chunks = chunkText(trimmed, 3800)
|
|
193
|
-
return {
|
|
194
|
-
replies: chunks.map((chunk, i) => {
|
|
195
|
-
const label = chunks.length > 1 ? ` (${i + 1}/${chunks.length})` : ''
|
|
196
|
-
return {
|
|
197
|
-
text: preBlock(chunk) + (label ? `\n<i>${label}</i>` : ''),
|
|
198
|
-
html: true,
|
|
199
|
-
}
|
|
200
|
-
}),
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return { replies: [{ text: preBlock(trimmed), html: true }] }
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ─── /restart handler impl ────────────────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
export interface RestartResult {
|
|
210
|
-
ok: boolean
|
|
211
|
-
text: string
|
|
212
|
-
html: boolean
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Core /restart implementation.
|
|
217
|
-
*
|
|
218
|
-
* Shells out to `systemctl --user restart switchroom-<agent>` via execFileSync
|
|
219
|
-
* (no shell, so agent name is safely passed as an arg — no injection risk).
|
|
220
|
-
*
|
|
221
|
-
* @param match Text after "/restart " from ctx.match
|
|
222
|
-
* @param execFile Injected execFileSync for testability
|
|
223
|
-
*/
|
|
224
|
-
export function handleRestartCommand(
|
|
225
|
-
match: string,
|
|
226
|
-
execFile: typeof execFileSync = execFileSync,
|
|
227
|
-
): RestartResult {
|
|
228
|
-
const agentName = match.trim().split(/\s+/)[0] ?? ''
|
|
229
|
-
|
|
230
|
-
if (!agentName) {
|
|
231
|
-
return {
|
|
232
|
-
ok: false,
|
|
233
|
-
text: 'Usage: /restart <agent>',
|
|
234
|
-
html: true,
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
try {
|
|
239
|
-
assertSafeAgentName(agentName)
|
|
240
|
-
} catch {
|
|
241
|
-
return { ok: false, text: 'Invalid agent name.', html: true }
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
try {
|
|
245
|
-
execFile(
|
|
246
|
-
'systemctl',
|
|
247
|
-
['--user', 'restart', `switchroom-${agentName}`],
|
|
248
|
-
{
|
|
249
|
-
encoding: 'utf-8',
|
|
250
|
-
timeout: 15000,
|
|
251
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
252
|
-
},
|
|
253
|
-
)
|
|
254
|
-
return {
|
|
255
|
-
ok: true,
|
|
256
|
-
text: `Restarted <code>switchroom-${escapeHtmlForTg(agentName)}</code>.`,
|
|
257
|
-
html: true,
|
|
258
|
-
}
|
|
259
|
-
} catch (err) {
|
|
260
|
-
const msg = err as { stderr?: string; stdout?: string; message?: string }
|
|
261
|
-
const detail = stripAnsi(msg.stderr || msg.stdout || msg.message || 'unknown error').trim()
|
|
262
|
-
return {
|
|
263
|
-
ok: false,
|
|
264
|
-
text: `<b>restart failed for ${escapeHtmlForTg(agentName)}:</b>\n${preBlock(formatSwitchroomOutput(detail))}`,
|
|
265
|
-
html: true,
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ─── /delete (destroy) handler impl ──────────────────────────────────────
|
|
271
|
-
|
|
272
|
-
export interface DeleteResult {
|
|
273
|
-
replies: Array<{ text: string; html: boolean }>
|
|
274
|
-
/** When true, foreman.ts should also send an inline keyboard for confirmation. */
|
|
275
|
-
needsConfirm?: boolean
|
|
276
|
-
/** Agent name (for the confirmation prompt). */
|
|
277
|
-
agentForConfirm?: string
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* Resolve the agents directory from environment or default location.
|
|
282
|
-
* Exposed for testability.
|
|
283
|
-
*/
|
|
284
|
-
export function resolveAgentsDirForDelete(): string {
|
|
285
|
-
const switchroomDir = process.env.SWITCHROOM_AGENTS_DIR
|
|
286
|
-
?? join(homedir(), '.switchroom', 'agents')
|
|
287
|
-
return switchroomDir
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Core /delete first-step implementation — returns a confirmation prompt.
|
|
292
|
-
*
|
|
293
|
-
* The actual deletion is performed by executeDeleteAgent() once the user
|
|
294
|
-
* confirms via callback_query or "YES" text.
|
|
295
|
-
*/
|
|
296
|
-
export function handleDeleteCommand(match: string): DeleteResult {
|
|
297
|
-
const agentName = match.trim().split(/\s+/)[0] ?? ''
|
|
298
|
-
|
|
299
|
-
if (!agentName) {
|
|
300
|
-
return {
|
|
301
|
-
replies: [{ text: 'Usage: /delete <agent>', html: true }],
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
try {
|
|
306
|
-
assertSafeAgentName(agentName)
|
|
307
|
-
} catch {
|
|
308
|
-
return { replies: [{ text: 'Invalid agent name.', html: true }] }
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return {
|
|
312
|
-
replies: [
|
|
313
|
-
{
|
|
314
|
-
text: `Are you sure you want to delete agent <b>${escapeHtmlForTg(agentName)}</b>?\n\nThis will stop and remove the systemd unit and archive the agent directory. Reply <b>YES</b> to confirm.`,
|
|
315
|
-
html: true,
|
|
316
|
-
},
|
|
317
|
-
],
|
|
318
|
-
needsConfirm: true,
|
|
319
|
-
agentForConfirm: agentName,
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Execute agent deletion after confirmation.
|
|
325
|
-
*
|
|
326
|
-
* Archives the agent dir to `agents/_archived_<name>_<timestamp>/` before
|
|
327
|
-
* running `switchroom agent destroy --yes <name>` so data is recoverable.
|
|
328
|
-
*
|
|
329
|
-
* @param agentName Validated agent name
|
|
330
|
-
* @param switchroomExec Injected CLI exec for testability
|
|
331
|
-
* @param execFile Injected execFileSync for testability (systemctl)
|
|
332
|
-
* @param agentsDir Override agents dir (for tests)
|
|
333
|
-
*/
|
|
334
|
-
export function executeDeleteAgent(
|
|
335
|
-
agentName: string,
|
|
336
|
-
switchroomExec: SwitchroomExecFn,
|
|
337
|
-
execFile: typeof execFileSync = execFileSync,
|
|
338
|
-
agentsDir: string = resolveAgentsDirForDelete(),
|
|
339
|
-
): DeleteResult {
|
|
340
|
-
try {
|
|
341
|
-
assertSafeAgentName(agentName)
|
|
342
|
-
} catch {
|
|
343
|
-
return { replies: [{ text: 'Invalid agent name.', html: true }] }
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const agentDir = resolve(agentsDir, agentName)
|
|
347
|
-
let archivePath: string | null = null
|
|
348
|
-
|
|
349
|
-
// Step 1: Archive the dir if it exists
|
|
350
|
-
if (existsSync(agentDir)) {
|
|
351
|
-
const timestamp = Date.now()
|
|
352
|
-
archivePath = resolve(agentsDir, `_archived_${agentName}_${timestamp}`)
|
|
353
|
-
try {
|
|
354
|
-
renameSync(agentDir, archivePath)
|
|
355
|
-
} catch (err) {
|
|
356
|
-
return {
|
|
357
|
-
replies: [
|
|
358
|
-
{
|
|
359
|
-
text: `<b>Archive failed for ${escapeHtmlForTg(agentName)}:</b>\n${preBlock(formatSwitchroomOutput((err as Error).message))}`,
|
|
360
|
-
html: true,
|
|
361
|
-
},
|
|
362
|
-
],
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Step 2: Stop + remove systemd unit via CLI (--yes skips the interactive prompt)
|
|
368
|
-
let cliOutput = ''
|
|
369
|
-
let cliOk = true
|
|
370
|
-
try {
|
|
371
|
-
cliOutput = switchroomExec(['agent', 'destroy', '--yes', agentName])
|
|
372
|
-
} catch (err) {
|
|
373
|
-
cliOk = false
|
|
374
|
-
const msg = err as { stderr?: string; stdout?: string; message?: string }
|
|
375
|
-
cliOutput = stripAnsi(msg.stderr || msg.stdout || msg.message || 'unknown error').trim()
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const lines: string[] = []
|
|
379
|
-
|
|
380
|
-
if (archivePath) {
|
|
381
|
-
lines.push(`Archived <code>${escapeHtmlForTg(agentName)}</code> to:`)
|
|
382
|
-
lines.push(`<code>${escapeHtmlForTg(archivePath)}</code>`)
|
|
383
|
-
lines.push('')
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
if (cliOk) {
|
|
387
|
-
lines.push(`Agent <b>${escapeHtmlForTg(agentName)}</b> deleted.`)
|
|
388
|
-
if (cliOutput.trim()) {
|
|
389
|
-
lines.push(preBlock(formatSwitchroomOutput(stripAnsi(cliOutput))))
|
|
390
|
-
}
|
|
391
|
-
} else {
|
|
392
|
-
lines.push(`<b>CLI destroy failed</b> (agent dir was archived; systemd unit may still exist):`)
|
|
393
|
-
lines.push(preBlock(formatSwitchroomOutput(cliOutput)))
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
return { replies: [{ text: lines.join('\n'), html: true }] }
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// ─── /version handler impl ────────────────────────────────────────────────
|
|
400
|
-
|
|
401
|
-
export interface VersionResult {
|
|
402
|
-
replies: Array<{ text: string; html: boolean }>
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Core /version implementation.
|
|
407
|
-
*
|
|
408
|
-
* Shells out to `switchroom version` and posts the output. Mirrors the
|
|
409
|
-
* /version handler in server.ts and gateway.ts — kept as a pure function
|
|
410
|
-
* here so tests can exercise it without a live bot.
|
|
411
|
-
*
|
|
412
|
-
* @param switchroomExec Injected CLI exec (combined stdout+stderr) for testability
|
|
413
|
-
*/
|
|
414
|
-
export function handleVersionCommand(
|
|
415
|
-
switchroomExec: SwitchroomExecFn,
|
|
416
|
-
): VersionResult {
|
|
417
|
-
let output: string
|
|
418
|
-
try {
|
|
419
|
-
output = switchroomExec(['version'])
|
|
420
|
-
} catch (err) {
|
|
421
|
-
const msg = err as { stderr?: string; stdout?: string; message?: string }
|
|
422
|
-
const detail = stripAnsi(msg.stderr || msg.stdout || msg.message || 'unknown error').trim()
|
|
423
|
-
return {
|
|
424
|
-
replies: [
|
|
425
|
-
{
|
|
426
|
-
text: `<b>version failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`,
|
|
427
|
-
html: true,
|
|
428
|
-
},
|
|
429
|
-
],
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const trimmed = stripAnsi(output).trim()
|
|
434
|
-
if (!trimmed) {
|
|
435
|
-
return { replies: [{ text: 'version: no output.', html: false }] }
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
return { replies: [{ text: preBlock(trimmed), html: true }] }
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// ─── /update handler impl ─────────────────────────────────────────────────
|
|
442
|
-
|
|
443
|
-
export interface UpdateResult {
|
|
444
|
-
replies: Array<{ text: string; html: boolean }>
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Core /update implementation.
|
|
449
|
-
*
|
|
450
|
-
* Shells out to `switchroom update` via the CLI exec helper. Output is
|
|
451
|
-
* paginated when > 3 KB.
|
|
452
|
-
*
|
|
453
|
-
* @param switchroomExec Injected CLI exec (combined stdout+stderr) for testability
|
|
454
|
-
*/
|
|
455
|
-
export function handleUpdateCommand(
|
|
456
|
-
switchroomExec: SwitchroomExecFn,
|
|
457
|
-
): UpdateResult {
|
|
458
|
-
let output: string
|
|
459
|
-
try {
|
|
460
|
-
output = switchroomExec(['update'])
|
|
461
|
-
} catch (err) {
|
|
462
|
-
const msg = err as { stderr?: string; stdout?: string; message?: string }
|
|
463
|
-
const detail = stripAnsi(msg.stderr || msg.stdout || msg.message || 'unknown error').trim()
|
|
464
|
-
return {
|
|
465
|
-
replies: [
|
|
466
|
-
{
|
|
467
|
-
text: `<b>update failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`,
|
|
468
|
-
html: true,
|
|
469
|
-
},
|
|
470
|
-
],
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
const trimmed = stripAnsi(output).trim()
|
|
475
|
-
if (!trimmed) {
|
|
476
|
-
return { replies: [{ text: 'Update complete (no output).', html: false }] }
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (Buffer.byteLength(trimmed, 'utf8') > LOG_PAGE_BYTES) {
|
|
480
|
-
const chunks = chunkText(trimmed, 3800)
|
|
481
|
-
return {
|
|
482
|
-
replies: chunks.map((chunk, i) => {
|
|
483
|
-
const label = chunks.length > 1 ? ` (${i + 1}/${chunks.length})` : ''
|
|
484
|
-
return {
|
|
485
|
-
text: preBlock(chunk) + (label ? `\n<i>${label}</i>` : ''),
|
|
486
|
-
html: true,
|
|
487
|
-
}
|
|
488
|
-
}),
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
return { replies: [{ text: preBlock(trimmed), html: true }] }
|
|
493
|
-
}
|