switchroom 0.11.1 → 0.12.1
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 +32 -16
- package/dist/agent-scheduler/index.js +216 -97
- package/dist/auth-broker/index.js +176 -97
- package/dist/cli/drive-write-pretool.mjs +26 -11
- package/dist/cli/skill-validate-pretool.mjs +7209 -0
- package/dist/cli/switchroom.js +45571 -42642
- package/dist/cli/ui/index.html +1281 -0
- package/dist/host-control/main.js +3628 -309
- package/dist/vault/approvals/kernel-server.js +207 -98
- package/dist/vault/broker/server.js +249 -119
- package/examples/personal-google-workspace-mcp/README.md +8 -3
- package/examples/switchroom.yaml +91 -42
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +76 -36
- package/profiles/_shared/agent-self-service.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +4 -2
- package/skills/file-bug/SKILL.md +6 -4
- package/skills/skill-creator/SKILL.md +52 -0
- package/skills/switchroom-cli/SKILL.md +20 -4
- package/skills/switchroom-install/SKILL.md +3 -3
- package/telegram-plugin/auth-snapshot-format.ts +9 -9
- package/telegram-plugin/card-format.ts +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +853 -414
- package/telegram-plugin/dist/server.js +162 -161
- package/telegram-plugin/format.ts +71 -0
- package/telegram-plugin/gateway/access-validator.test.ts +8 -8
- package/telegram-plugin/gateway/access-validator.ts +1 -1
- package/telegram-plugin/gateway/approval-card.test.ts +18 -18
- package/telegram-plugin/gateway/approval-card.ts +1 -1
- package/telegram-plugin/gateway/auth-command.ts +2 -2
- package/telegram-plugin/gateway/boot-card.ts +40 -3
- package/telegram-plugin/gateway/boot-probes.ts +114 -30
- 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 +265 -22
- package/telegram-plugin/gateway/update-announce.ts +167 -0
- package/telegram-plugin/quota-check.ts +0 -195
- package/telegram-plugin/recent-outbound-dedup.ts +1 -1
- package/telegram-plugin/registry/turns-schema.ts +1 -1
- 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 +32 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +17 -17
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +10 -10
- package/telegram-plugin/tests/boot-probes.test.ts +90 -2
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -1
- package/telegram-plugin/tests/fixtures/service-log-current-claude-code.bin +1 -1
- package/telegram-plugin/tests/fleet-state.test.ts +3 -2
- 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/secret-detect-audit.test.ts +1 -1
- package/telegram-plugin/tests/secret-detect-pipeline.test.ts +7 -6
- package/telegram-plugin/tests/secret-detect-suppressor-no-silent-allow.test.ts +6 -5
- package/telegram-plugin/tests/secret-detect.test.ts +8 -8
- package/telegram-plugin/tests/telegram-format.test.ts +84 -1
- package/telegram-plugin/tests/update-announce.test.ts +154 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +8 -8
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +51 -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
|
@@ -41,8 +41,8 @@ describe("buildDiffPreviewCard — suggest mode (default)", () => {
|
|
|
41
41
|
const preview = buildDiffPreview(baseInput({ agentSummary: "Added Hiring section" }));
|
|
42
42
|
const card = buildDiffPreviewCard({
|
|
43
43
|
preview,
|
|
44
|
-
suggestRequestId: "
|
|
45
|
-
writeRequestId: "
|
|
44
|
+
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
45
|
+
writeRequestId: "11223344112233441122334411223344",
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// Body: title bold + all preview lines.
|
|
@@ -59,19 +59,19 @@ describe("buildDiffPreviewCard — suggest mode (default)", () => {
|
|
|
59
59
|
expect(r[0]?.[0]?.text).toBe("📖 Open in Drive");
|
|
60
60
|
expect(r[0]?.[0]?.url).toBe("https://docs.google.com/document/d/DOC1/edit");
|
|
61
61
|
expect(r[0]?.[1]?.text).toBe("✅ Apply as suggestion");
|
|
62
|
-
expect(r[0]?.[1]?.callback_data).toBe("apv:
|
|
62
|
+
expect(r[0]?.[1]?.callback_data).toBe("apv:aabbccddaabbccddaabbccddaabbccdd:once");
|
|
63
63
|
// Row 2: [Apply directly] [Cancel]
|
|
64
64
|
expect(r[1]?.[0]?.text).toBe("⚠ Apply directly");
|
|
65
|
-
expect(r[1]?.[0]?.callback_data).toBe("apv:
|
|
65
|
+
expect(r[1]?.[0]?.callback_data).toBe("apv:11223344112233441122334411223344:once");
|
|
66
66
|
expect(r[1]?.[1]?.text).toBe("🚫 Cancel");
|
|
67
|
-
expect(r[1]?.[1]?.callback_data).toBe("apv:
|
|
67
|
+
expect(r[1]?.[1]?.callback_data).toBe("apv:aabbccddaabbccddaabbccddaabbccdd:deny");
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
it("hides 'Apply directly' when writeRequestId is undefined", () => {
|
|
71
71
|
const preview = buildDiffPreview(baseInput());
|
|
72
72
|
const card = buildDiffPreviewCard({
|
|
73
73
|
preview,
|
|
74
|
-
suggestRequestId: "
|
|
74
|
+
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
75
75
|
});
|
|
76
76
|
const flat = rows(card.reply_markup).flat();
|
|
77
77
|
expect(flat.find((b) => b.text === "⚠ Apply directly")).toBeUndefined();
|
|
@@ -91,8 +91,8 @@ describe("buildDiffPreviewCard — write mode (opt-in via expand)", () => {
|
|
|
91
91
|
// callback's deny channel — semantically Cancel is "don't grant
|
|
92
92
|
// either scope" but reusing the suggest id keeps the existing
|
|
93
93
|
// approval-callback handler stateless.
|
|
94
|
-
suggestRequestId: "
|
|
95
|
-
writeRequestId: "
|
|
94
|
+
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
95
|
+
writeRequestId: "11223344112233441122334411223344",
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
const r = rows(card.reply_markup);
|
|
@@ -100,7 +100,7 @@ describe("buildDiffPreviewCard — write mode (opt-in via expand)", () => {
|
|
|
100
100
|
expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeUndefined();
|
|
101
101
|
const directly = flat.find((b) => b.text === "⚠ Apply directly");
|
|
102
102
|
expect(directly).toBeDefined();
|
|
103
|
-
expect(directly?.callback_data).toBe("apv:
|
|
103
|
+
expect(directly?.callback_data).toBe("apv:11223344112233441122334411223344:once");
|
|
104
104
|
// Title icon swaps to ⚠.
|
|
105
105
|
expect(card.text).toContain("⚠");
|
|
106
106
|
});
|
|
@@ -119,7 +119,7 @@ describe("buildDiffPreviewCard — input validation", () => {
|
|
|
119
119
|
expect(() =>
|
|
120
120
|
buildDiffPreviewCard({
|
|
121
121
|
preview,
|
|
122
|
-
suggestRequestId: "
|
|
122
|
+
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
123
123
|
writeRequestId: "ABCDEF01", // wrong case
|
|
124
124
|
}),
|
|
125
125
|
).toThrow(/8 hex chars/);
|
|
@@ -136,7 +136,7 @@ describe("buildDiffPreviewCard — fragility guards", () => {
|
|
|
136
136
|
);
|
|
137
137
|
const card = buildDiffPreviewCard({
|
|
138
138
|
preview,
|
|
139
|
-
suggestRequestId: "
|
|
139
|
+
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
140
140
|
});
|
|
141
141
|
const flat = rows(card.reply_markup).flat();
|
|
142
142
|
expect(flat.find((b) => b.text === "📖 Open in Drive")).toBeUndefined();
|
|
@@ -150,7 +150,7 @@ describe("buildDiffPreviewCard — fragility guards", () => {
|
|
|
150
150
|
);
|
|
151
151
|
const card = buildDiffPreviewCard({
|
|
152
152
|
preview,
|
|
153
|
-
suggestRequestId: "
|
|
153
|
+
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
154
154
|
});
|
|
155
155
|
expect(card.text).not.toContain("<script>");
|
|
156
156
|
expect(card.text).toContain("<script>");
|
|
@@ -162,7 +162,7 @@ describe("buildDiffPreviewCard — fragility guards", () => {
|
|
|
162
162
|
);
|
|
163
163
|
const card = buildDiffPreviewCard({
|
|
164
164
|
preview,
|
|
165
|
-
suggestRequestId: "
|
|
165
|
+
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
166
166
|
});
|
|
167
167
|
expect(card.text).not.toMatch(/💬.*<b>/);
|
|
168
168
|
expect(card.text).toContain("<b>");
|
|
@@ -175,8 +175,8 @@ describe("buildDiffPreviewCard — audit fidelity", () => {
|
|
|
175
175
|
const preview = buildDiffPreview(input);
|
|
176
176
|
const card = buildDiffPreviewCard({
|
|
177
177
|
preview,
|
|
178
|
-
suggestRequestId: "
|
|
179
|
-
writeRequestId: "
|
|
178
|
+
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
179
|
+
writeRequestId: "11223344112233441122334411223344",
|
|
180
180
|
});
|
|
181
181
|
// The audit row captures both wrapper truth + agent framing,
|
|
182
182
|
// exactly as surfaced on the card.
|
|
@@ -55,7 +55,7 @@ export interface DiffPreviewCardInput {
|
|
|
55
55
|
* callback_data that the dispatcher rejects, but we'd rather fail
|
|
56
56
|
* loudly at build time.
|
|
57
57
|
*/
|
|
58
|
-
const REQUEST_ID_RE = /^[0-9a-f]{
|
|
58
|
+
const REQUEST_ID_RE = /^[0-9a-f]{32}$/;
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* Fragility-guard from B2 review: the `create_doc` prep helper
|
|
@@ -66,7 +66,7 @@ function deps(overrides: Partial<DriveApprovalHandlerDeps> & { spy: Spy }): Driv
|
|
|
66
66
|
ttl_ms: args.ttl_ms,
|
|
67
67
|
approver_set: args.approver_set,
|
|
68
68
|
});
|
|
69
|
-
return { request_id: "
|
|
69
|
+
return { request_id: "aabbccddaabbccddaabbccddaabbccdd", expires_at_ms: Date.now() + args.ttl_ms };
|
|
70
70
|
},
|
|
71
71
|
postCard: async (args) => {
|
|
72
72
|
spy.posted.push({
|
|
@@ -123,7 +123,7 @@ describe("handleRequestDriveApproval — happy path", () => {
|
|
|
123
123
|
type: "drive_approval_posted",
|
|
124
124
|
correlationId: "corr-1",
|
|
125
125
|
ok: true,
|
|
126
|
-
requestId: "
|
|
126
|
+
requestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
127
127
|
});
|
|
128
128
|
expect(spy.sent[0]?.expiresAtMs).toBeGreaterThan(Date.now());
|
|
129
129
|
});
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
createRetryApiCall,
|
|
63
63
|
createSwallowingRetryApiCall,
|
|
64
64
|
retryWithThreadFallback,
|
|
65
|
+
isHtmlParseRejectError,
|
|
65
66
|
} from '../retry-api-call.js'
|
|
66
67
|
import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
|
|
67
68
|
import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
|
|
@@ -137,7 +138,7 @@ import { validateStringArray } from './access-validator.js'
|
|
|
137
138
|
* identical envelope shapes.
|
|
138
139
|
*/
|
|
139
140
|
const REPLY_TO_TEXT_MAX = 200
|
|
140
|
-
import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace } from '../format.js'
|
|
141
|
+
import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
|
|
141
142
|
import {
|
|
142
143
|
validateInlineKeyboard,
|
|
143
144
|
type AnyButton,
|
|
@@ -213,6 +214,7 @@ import {
|
|
|
213
214
|
import {
|
|
214
215
|
fetchQuota,
|
|
215
216
|
formatQuotaBlock,
|
|
217
|
+
type QuotaResult,
|
|
216
218
|
} from '../quota-check.js'
|
|
217
219
|
import {
|
|
218
220
|
loadLockout,
|
|
@@ -301,6 +303,7 @@ import {
|
|
|
301
303
|
} from './boot-card.js'
|
|
302
304
|
import { determineRestartReason } from './boot-reason.js'
|
|
303
305
|
import { shouldSkipDuplicateBootCard, type RestartReason } from './boot-card.js'
|
|
306
|
+
import { maybeRenderUpdateAnnouncement } from './update-announce.js'
|
|
304
307
|
import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
|
|
305
308
|
import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
|
|
306
309
|
import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
|
|
@@ -2775,6 +2778,12 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
2775
2778
|
// second bridge-reconnect in the same lifetime can't race against
|
|
2776
2779
|
// an in-flight sendMessage here either (#489).
|
|
2777
2780
|
bootCardPending = true
|
|
2781
|
+
// PR C: surface the most recent terminal update_apply audit
|
|
2782
|
+
// row if it lands within the lookback window AND no other
|
|
2783
|
+
// boot has claimed it. Cheap (one file read + one O_EXCL).
|
|
2784
|
+
const updateOutcomeLine = (() => {
|
|
2785
|
+
try { return maybeRenderUpdateAnnouncement() ?? undefined } catch { return undefined }
|
|
2786
|
+
})()
|
|
2778
2787
|
startBootCard(chatId, threadId, botApiForCard, {
|
|
2779
2788
|
agentName: agentDisplayName,
|
|
2780
2789
|
agentSlug,
|
|
@@ -2785,8 +2794,10 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
2785
2794
|
restartAgeMs: markerAgeMs,
|
|
2786
2795
|
restartReasonDetail: cleanMarker?.reason,
|
|
2787
2796
|
loadAccounts: () => loadAccountsForBootCard(agentSlug),
|
|
2797
|
+
probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentSlug, t),
|
|
2788
2798
|
tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
|
|
2789
2799
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
2800
|
+
...(updateOutcomeLine ? { updateOutcomeLine } : {}),
|
|
2790
2801
|
}, ackMsgId).then(handle => {
|
|
2791
2802
|
activeBootCard = handle
|
|
2792
2803
|
}).catch((err: Error) => {
|
|
@@ -3484,6 +3495,31 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
3484
3495
|
}
|
|
3485
3496
|
}
|
|
3486
3497
|
|
|
3498
|
+
// Last-resort: resend this chunk as plain text (parse_mode unset).
|
|
3499
|
+
// Keeps thread / reply / markup params; only the formatting is
|
|
3500
|
+
// sacrificed. Used when Telegram rejects our HTML — better an
|
|
3501
|
+
// unformatted answer than a vanished one.
|
|
3502
|
+
const sendChunkPlainText = async (opts: Record<string, unknown>): Promise<void> => {
|
|
3503
|
+
const plainOpts = { ...opts }
|
|
3504
|
+
delete (plainOpts as { parse_mode?: unknown }).parse_mode
|
|
3505
|
+
// A chunk that was pure markup (no text content) strips to ''.
|
|
3506
|
+
// Sending '' is a Telegram 400 "message text is empty" — i.e.
|
|
3507
|
+
// the answer would still vanish, in the exact path that exists
|
|
3508
|
+
// to prevent that. Substitute an honest placeholder so the
|
|
3509
|
+
// user at least sees that a fragment was unrenderable.
|
|
3510
|
+
const stripped = telegramHtmlToPlainText(chunks[i])
|
|
3511
|
+
const plain =
|
|
3512
|
+
stripped.length > 0
|
|
3513
|
+
? stripped
|
|
3514
|
+
: '⚠️ (a formatted fragment could not be rendered for Telegram)'
|
|
3515
|
+
const sent = await lockedBot.api.sendMessage(chat_id, plain, plainOpts as never)
|
|
3516
|
+
sentIds.push(sent.message_id)
|
|
3517
|
+
logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
|
|
3518
|
+
process.stderr.write(
|
|
3519
|
+
`telegram gateway: HTML parse-reject — resent chunk ${i + 1}/${chunks.length} as plain text\n`,
|
|
3520
|
+
)
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3487
3523
|
try {
|
|
3488
3524
|
const sent = await robustApiCall(
|
|
3489
3525
|
() => lockedBot.api.sendMessage(chat_id, chunks[i], sendOpts as never),
|
|
@@ -3496,8 +3532,16 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
3496
3532
|
threadId = undefined
|
|
3497
3533
|
const retryOpts = { ...sendOpts }
|
|
3498
3534
|
delete (retryOpts as any).message_thread_id
|
|
3499
|
-
|
|
3500
|
-
|
|
3535
|
+
try {
|
|
3536
|
+
const sent = await lockedBot.api.sendMessage(chat_id, chunks[i], retryOpts as never)
|
|
3537
|
+
sentIds.push(sent.message_id)
|
|
3538
|
+
} catch (retryErr) {
|
|
3539
|
+
// Thread dropped, but the HTML is also unparseable — go plain.
|
|
3540
|
+
if (isHtmlParseRejectError(retryErr)) await sendChunkPlainText(retryOpts)
|
|
3541
|
+
else throw retryErr
|
|
3542
|
+
}
|
|
3543
|
+
} else if (isHtmlParseRejectError(err)) {
|
|
3544
|
+
await sendChunkPlainText(sendOpts)
|
|
3501
3545
|
} else {
|
|
3502
3546
|
throw err
|
|
3503
3547
|
}
|
|
@@ -4460,6 +4504,44 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
4460
4504
|
|
|
4461
4505
|
const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
|
|
4462
4506
|
|
|
4507
|
+
// Fix B (#1487 follow-up): if this agent's STANDING ACL already
|
|
4508
|
+
// covers the key, do NOT render a card or mint a grant. Minting
|
|
4509
|
+
// writes a `.vault-token` that — pre-#1487 — *shadowed* the standing
|
|
4510
|
+
// ACL (the exact gymbro trap) and is simply redundant post-#1487.
|
|
4511
|
+
// Determine coverage AUTHORITATIVELY by probing the broker AS THIS
|
|
4512
|
+
// AGENT (no-token list over the per-agent socket — path-as-identity;
|
|
4513
|
+
// the gateway runs in the agent's container so the broker attributes
|
|
4514
|
+
// it to this agent). NOT a gateway-side config read: the gateway can
|
|
4515
|
+
// see newer config than the broker has loaded, so a config-derived
|
|
4516
|
+
// "covered" could be wrong where the broker still denies. `list`
|
|
4517
|
+
// returns only ACL-visible key NAMES — never secret values. Read
|
|
4518
|
+
// scope only: schedule.secrets[] confers read, not write.
|
|
4519
|
+
if (scopeRaw === 'read') {
|
|
4520
|
+
try {
|
|
4521
|
+
const visible = await listViaBroker()
|
|
4522
|
+
if (visible !== null && visible.includes(key)) {
|
|
4523
|
+
return {
|
|
4524
|
+
content: [
|
|
4525
|
+
{
|
|
4526
|
+
type: 'text',
|
|
4527
|
+
text:
|
|
4528
|
+
`vault_request_access: '${key}' is ALREADY covered by ${agentSlug}'s ` +
|
|
4529
|
+
`standing ACL (schedule.secrets[]). No approval card or grant is needed — ` +
|
|
4530
|
+
`read it directly: \`switchroom vault get ${key}\`. Do NOT request a grant ` +
|
|
4531
|
+
`for this key (a minted token would shadow the standing ACL). If a read ` +
|
|
4532
|
+
`still returns VAULT-BROKER-DENIED, the broker likely needs a restart to ` +
|
|
4533
|
+
`pick up a recent config change — tell the operator; don't re-request.`,
|
|
4534
|
+
},
|
|
4535
|
+
],
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
} catch {
|
|
4539
|
+
// Probe failed (broker unreachable / transient): fall through to
|
|
4540
|
+
// the normal card flow. Fail-open is correct here — a redundant
|
|
4541
|
+
// card is harmless; suppressing a needed card is not.
|
|
4542
|
+
}
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4463
4545
|
const stageId = randomBytes(4).toString('hex')
|
|
4464
4546
|
const pending: PendingVaultRequestAccess = {
|
|
4465
4547
|
agent: agentSlug,
|
|
@@ -6658,7 +6740,7 @@ async function handleInbound(
|
|
|
6658
6740
|
} else {
|
|
6659
6741
|
// Fresh turn — priorTurnInFlight is false, so priorActive is
|
|
6660
6742
|
// provably undefined. Earlier `if (priorActive)` block was dead
|
|
6661
|
-
// code
|
|
6743
|
+
// code, removed in the same first-paint cleanup pass.
|
|
6662
6744
|
const sKey = streamKey(chat_id, messageThreadId)
|
|
6663
6745
|
const priorStream = activeDraftStreams.get(sKey)
|
|
6664
6746
|
if (priorStream && !priorStream.isFinal()) {
|
|
@@ -7770,26 +7852,65 @@ async function runSwitchroomCommandFormatted(ctx: Context, args: string[], label
|
|
|
7770
7852
|
// every agent can self-restart without admin privilege. `/restart <other>`
|
|
7771
7853
|
// is blocked just like any other admin verb.
|
|
7772
7854
|
//
|
|
7773
|
-
//
|
|
7774
|
-
//
|
|
7855
|
+
// sec WS7-F2 (#1394): when AGENT_ADMIN=true this middleware is NO LONGER a
|
|
7856
|
+
// no-op — a `block`-classified verb (fleet-admin / `/restart <other>`)
|
|
7857
|
+
// requires OPERATOR-PRIVATE (a private chat from a strict
|
|
7858
|
+
// `access.allowFrom` sender), because the per-command `isAuthorizedSender`
|
|
7859
|
+
// gate treats an empty group `allowFrom` as "allow every member". Non-
|
|
7860
|
+
// admin-verb traffic (`pass-through`, incl. `/restart`-self and all normal
|
|
7861
|
+
// chat) is untouched and reaches `next()` exactly as before.
|
|
7775
7862
|
bot.use(async (ctx, next) => {
|
|
7776
|
-
if (
|
|
7863
|
+
if (ctx.message?.text) {
|
|
7777
7864
|
const myName = getMyAgentName()
|
|
7778
7865
|
const decision = classifyAdminGate(ctx.message.text, myName)
|
|
7779
7866
|
if (decision.action === 'block') {
|
|
7780
|
-
//
|
|
7781
|
-
//
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
)
|
|
7867
|
+
// `block` = a fleet-admin verb (ADMIN_COMMAND_NAMES) or
|
|
7868
|
+
// `/restart <other-agent>`. classifyAdminGate already lets
|
|
7869
|
+
// `/restart`-self and every non-admin command pass through, so
|
|
7870
|
+
// this branch is exactly the privileged set.
|
|
7785
7871
|
const cmdHtml = escapeHtmlForTg(`/${decision.cmd}`)
|
|
7786
7872
|
const nameHtml = escapeHtmlForTg(myName)
|
|
7787
|
-
const
|
|
7873
|
+
const notFlagged =
|
|
7788
7874
|
decision.reason === 'other-agent'
|
|
7789
7875
|
? `⚠️ <code>${cmdHtml}</code> targeting another agent is an admin operation — this agent (<code>${nameHtml}</code>) isn't admin-flagged. Run it from an admin agent, or set <code>admin: true</code> for this agent in switchroom.yaml. (Self-restart is allowed: send <code>/restart</code> with no arg.)`
|
|
7790
7876
|
: `⚠️ <code>${cmdHtml}</code> is an admin command — this agent (<code>${nameHtml}</code>) isn't admin-flagged. Run it from an admin agent, or set <code>admin: true</code> for this agent in switchroom.yaml.`
|
|
7791
|
-
|
|
7792
|
-
|
|
7877
|
+
if (!AGENT_ADMIN) {
|
|
7878
|
+
// Unchanged behaviour: a non-admin agent never executes admin
|
|
7879
|
+
// verbs locally and must not forward them to Claude.
|
|
7880
|
+
process.stderr.write(
|
|
7881
|
+
`telegram gateway: admin-gate blocked cmd=/${decision.cmd} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} reason=${decision.reason} (AGENT_ADMIN=false)\n`,
|
|
7882
|
+
)
|
|
7883
|
+
await switchroomReply(ctx, notFlagged, { html: true })
|
|
7884
|
+
return
|
|
7885
|
+
}
|
|
7886
|
+
// sec WS7-F2 (#1394): fleet-admin is OPERATOR-PRIVATE. Honor it
|
|
7887
|
+
// ONLY in a private chat from an `access.allowFrom` sender.
|
|
7888
|
+
// Before this, when AGENT_ADMIN=true the middleware was a no-op
|
|
7889
|
+
// and the per-command `isAuthorizedSender` gate treats an empty
|
|
7890
|
+
// group `allowFrom` as "allow every member" — so any member of
|
|
7891
|
+
// an admin agent's forum/group could run /vault, /update apply,
|
|
7892
|
+
// /grant, /dangerous, etc. (the default shape for an agent
|
|
7893
|
+
// created via `agent add --topology forum` + `admin: true`).
|
|
7894
|
+
// Strict `access.allowFrom` + private-chat-only — never the
|
|
7895
|
+
// group-permissive isAuthorizedSender.
|
|
7896
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
7897
|
+
const operatorPrivate =
|
|
7898
|
+
ctx.chat?.type === 'private' &&
|
|
7899
|
+
loadAccess().allowFrom.includes(senderId)
|
|
7900
|
+
if (!operatorPrivate) {
|
|
7901
|
+
process.stderr.write(
|
|
7902
|
+
`telegram gateway: admin-gate refused (not operator-private) cmd=/${decision.cmd} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} chat=${ctx.chat?.type ?? '?'} sender=${senderId}\n`,
|
|
7903
|
+
)
|
|
7904
|
+
await switchroomReply(
|
|
7905
|
+
ctx,
|
|
7906
|
+
`⚠️ <code>${cmdHtml}</code> is a fleet-admin command — it is <b>operator-private</b>. Send it as a direct message to me from your operator account (a private chat where your Telegram ID is on the access allowlist), not in a group or forum.`,
|
|
7907
|
+
{ html: true },
|
|
7908
|
+
)
|
|
7909
|
+
return
|
|
7910
|
+
}
|
|
7911
|
+
// operator-private admin verb on an admin agent → fall through
|
|
7912
|
+
// to the bot.command() handler (which re-checks isAuthorizedSender
|
|
7913
|
+
// — redundant but harmless in a private allowFrom chat).
|
|
7793
7914
|
}
|
|
7794
7915
|
}
|
|
7795
7916
|
await next()
|
|
@@ -7958,6 +8079,7 @@ async function buildLiveProbeRows(agentName: string): Promise<StatusProbeRow[]>
|
|
|
7958
8079
|
gatewayInfo: { pid: process.pid, startedAtMs: GATEWAY_STARTED_AT_MS },
|
|
7959
8080
|
tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
|
|
7960
8081
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
8082
|
+
probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentName, t),
|
|
7961
8083
|
})
|
|
7962
8084
|
const rows: StatusProbeRow[] = []
|
|
7963
8085
|
// Render order matches the boot card's PROBE_KEYS so the two
|
|
@@ -8574,7 +8696,7 @@ bot.command('audit', async ctx => {
|
|
|
8574
8696
|
if (arg === '' || arg === 'help' || arg === '--help') {
|
|
8575
8697
|
await switchroomReply(
|
|
8576
8698
|
ctx,
|
|
8577
|
-
'Usage: <code>/audit hostd [--tail N] [--agent <name>] [--op <verb>] [--error]</code>',
|
|
8699
|
+
'Usage: <code>/audit hostd [--tail N] [--agent <name>] [--op <verb>] [--error] [--verbose]</code>',
|
|
8578
8700
|
{ html: true },
|
|
8579
8701
|
)
|
|
8580
8702
|
return
|
|
@@ -8601,6 +8723,7 @@ bot.command('audit', async ctx => {
|
|
|
8601
8723
|
for (let i = 1; i < tokens.length; i++) {
|
|
8602
8724
|
const t = tokens[i]!
|
|
8603
8725
|
if (t === '--error') { argv.push('--error'); continue }
|
|
8726
|
+
if (t === '--verbose') { argv.push('--verbose'); continue }
|
|
8604
8727
|
if (t === '--tail' || t === '--agent' || t === '--op') {
|
|
8605
8728
|
const v = tokens[++i]
|
|
8606
8729
|
if (v == null) {
|
|
@@ -8630,7 +8753,7 @@ bot.command('audit', async ctx => {
|
|
|
8630
8753
|
await switchroomReply(
|
|
8631
8754
|
ctx,
|
|
8632
8755
|
`Unknown flag <code>${escapeHtmlForTg(t)}</code>. ` +
|
|
8633
|
-
`Allowed: <code>--tail</code>, <code>--agent</code>, <code>--op</code>, <code>--error</code>.`,
|
|
8756
|
+
`Allowed: <code>--tail</code>, <code>--agent</code>, <code>--op</code>, <code>--error</code>, <code>--verbose</code>.`,
|
|
8634
8757
|
{ html: true },
|
|
8635
8758
|
)
|
|
8636
8759
|
return
|
|
@@ -9011,7 +9134,39 @@ async function runCreditWatch(): Promise<void> {
|
|
|
9011
9134
|
}
|
|
9012
9135
|
|
|
9013
9136
|
bot.command("auth", async ctx => {
|
|
9014
|
-
|
|
9137
|
+
// sec WS7-F2b (#1394): `/auth` drives the auth-broker credential
|
|
9138
|
+
// lifecycle (`/auth add` mints/attaches an Anthropic account token,
|
|
9139
|
+
// `/auth use` switches the active account, …). It is NOT in
|
|
9140
|
+
// ADMIN_COMMAND_NAMES (deliberately gateway-handled even on
|
|
9141
|
+
// non-admin agents so it works when the model is unreachable — that
|
|
9142
|
+
// routing is unchanged), so the WS7-F2 operator-private middleware
|
|
9143
|
+
// does not cover it, and its only sender gate was the
|
|
9144
|
+
// group-permissive `isAuthorizedSender` (empty group `allowFrom` =
|
|
9145
|
+
// allow every member). On an `admin:true` forum agent any
|
|
9146
|
+
// forum/supergroup member could therefore run privileged `/auth`.
|
|
9147
|
+
// Credential-plane admin is OPERATOR-PRIVATE, exactly like the
|
|
9148
|
+
// ADMIN_COMMAND_NAMES verbs (WS7-F2 / #1408): honor `/auth` ONLY in
|
|
9149
|
+
// a private chat from a strict `access.allowFrom` sender — never the
|
|
9150
|
+
// group-permissive isAuthorizedSender. The agent-admin-flag /
|
|
9151
|
+
// broker-side enforcement (isAuthAdmin below) is orthogonal and
|
|
9152
|
+
// unchanged; operator auth-recovery is via DM (same as #1408).
|
|
9153
|
+
const authSenderId = String(ctx.from?.id ?? '')
|
|
9154
|
+
const authOperatorPrivate =
|
|
9155
|
+
ctx.chat?.type === 'private' &&
|
|
9156
|
+
loadAccess().allowFrom.includes(authSenderId)
|
|
9157
|
+
if (!authOperatorPrivate) {
|
|
9158
|
+
if (ctx.chat?.type !== 'private') {
|
|
9159
|
+
process.stderr.write(
|
|
9160
|
+
`telegram gateway: /auth refused (not operator-private) agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} chat=${ctx.chat?.type ?? '?'} sender=${authSenderId}\n`,
|
|
9161
|
+
)
|
|
9162
|
+
await switchroomReply(
|
|
9163
|
+
ctx,
|
|
9164
|
+
`⚠️ <code>/auth</code> manages account credentials — it is <b>operator-private</b>. Send it as a direct message to me from your operator account (a private chat where your Telegram ID is on the access allowlist), not in a group or forum.`,
|
|
9165
|
+
{ html: true },
|
|
9166
|
+
).catch(() => {})
|
|
9167
|
+
}
|
|
9168
|
+
return
|
|
9169
|
+
}
|
|
9015
9170
|
const text = ctx.message?.text ?? ""
|
|
9016
9171
|
const parsed = parseAuthCommand(text)
|
|
9017
9172
|
if (!parsed) return
|
|
@@ -9174,6 +9329,33 @@ async function loadAccountsForBootCard(agent: string): Promise<ListStateData | n
|
|
|
9174
9329
|
}
|
|
9175
9330
|
}
|
|
9176
9331
|
|
|
9332
|
+
/**
|
|
9333
|
+
* Canonical boot-card quota probe (#1336): resolve this agent's
|
|
9334
|
+
* effective account, then have the broker probe Anthropic server-side.
|
|
9335
|
+
* Returns null on any failure (broker unreachable, no active account)
|
|
9336
|
+
* so `probeQuota` falls back to a direct probe. Mirrors
|
|
9337
|
+
* `loadAccountsForBootCard`'s broker-client + swallow-to-null shape,
|
|
9338
|
+
* and the override→account→active resolution used by auth-line.ts.
|
|
9339
|
+
*/
|
|
9340
|
+
async function probeQuotaForBootCard(
|
|
9341
|
+
agent: string,
|
|
9342
|
+
timeoutMs?: number,
|
|
9343
|
+
): Promise<QuotaResult | null> {
|
|
9344
|
+
try {
|
|
9345
|
+
const client = await getAuthBrokerClient(agent)
|
|
9346
|
+
if (!client) return null
|
|
9347
|
+
const state = await client.listState()
|
|
9348
|
+
const entry = state.agents.find((a) => a.name === agent)
|
|
9349
|
+
const label = entry?.override ?? entry?.account ?? state.active
|
|
9350
|
+
if (!label) return null
|
|
9351
|
+
const { results } = await client.probeQuota([label], timeoutMs)
|
|
9352
|
+
return results.find((r) => r.label === label)?.result ?? null
|
|
9353
|
+
} catch (err) {
|
|
9354
|
+
process.stderr.write(`telegram gateway: boot-card quota probe failed: ${(err as Error)?.message ?? String(err)}\n`)
|
|
9355
|
+
return null
|
|
9356
|
+
}
|
|
9357
|
+
}
|
|
9358
|
+
|
|
9177
9359
|
/**
|
|
9178
9360
|
* Read the pending auth session's target slot from the agent's
|
|
9179
9361
|
* `.setup-token.session.json` meta file. Returns null when no session
|
|
@@ -9408,6 +9590,40 @@ async function performVaultAccessApproval(
|
|
|
9408
9590
|
attestation.kind === 'passphrase'
|
|
9409
9591
|
? { passphrase: attestation.passphrase }
|
|
9410
9592
|
: { attest_via_posture: true as const }
|
|
9593
|
+
|
|
9594
|
+
// Fix B (#1487 follow-up), operator-tap guard. Defense-in-depth for a
|
|
9595
|
+
// card staged before the key became standing-ACL-covered (config edit
|
|
9596
|
+
// / #1487 deploy / drift): if the agent's standing ACL ALREADY covers
|
|
9597
|
+
// this read key, do NOT mint — minting writes a `.vault-token` that
|
|
9598
|
+
// shadows the standing ACL and is redundant. Authoritative broker
|
|
9599
|
+
// probe AS THIS AGENT (no-token list over the per-agent socket — same
|
|
9600
|
+
// rationale as executeVaultRequestAccess; never a gateway-side config
|
|
9601
|
+
// read). Read scope only. Fail-open on probe error (mint as before).
|
|
9602
|
+
if (pending.scope === 'read') {
|
|
9603
|
+
try {
|
|
9604
|
+
const visible = await listViaBroker()
|
|
9605
|
+
if (visible !== null && visible.includes(pending.key)) {
|
|
9606
|
+
pendingVaultRequestAccesses.delete(stageId)
|
|
9607
|
+
if (pending.card_message_id != null) {
|
|
9608
|
+
await ctx.api
|
|
9609
|
+
.editMessageText(
|
|
9610
|
+
pending.chat_id,
|
|
9611
|
+
pending.card_message_id,
|
|
9612
|
+
`ℹ️ <b>${escapeHtmlForTg(pending.agent)}</b> already has standing-ACL access to ` +
|
|
9613
|
+
`<code>${escapeHtmlForTg(pending.key)}</code> (schedule.secrets[]). ` +
|
|
9614
|
+
`<b>No grant minted</b> — a token would shadow the standing ACL. ` +
|
|
9615
|
+
`The agent can read it directly.`,
|
|
9616
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
9617
|
+
)
|
|
9618
|
+
.catch(() => {})
|
|
9619
|
+
}
|
|
9620
|
+
return
|
|
9621
|
+
}
|
|
9622
|
+
} catch {
|
|
9623
|
+
// Probe failed: fall through and mint as before (fail-open).
|
|
9624
|
+
}
|
|
9625
|
+
}
|
|
9626
|
+
|
|
9411
9627
|
// #1051: union the new key with the agent's existing active grant
|
|
9412
9628
|
// before minting. Without this, each fresh Approve OVERWRITES the
|
|
9413
9629
|
// agent's `.vault-token` file with a single-key grant — the
|
|
@@ -11501,7 +11717,24 @@ bot.on('callback_query:data', async ctx => {
|
|
|
11501
11717
|
// Routed through the generic kernel handler so any surface that uses
|
|
11502
11718
|
// buildApprovalCard inherits consume → record → confirmation UX without
|
|
11503
11719
|
// each surface re-implementing it.
|
|
11720
|
+
//
|
|
11721
|
+
// SECURITY (sec WS7-F1, #1394): this is the human-in-the-loop gate for
|
|
11722
|
+
// EVERY dangerous tool call + Drive write. handleApprovalCallback records
|
|
11723
|
+
// whoever tapped as the approver (`granted_by_user_id = ctx.from.id`) and
|
|
11724
|
+
// the approval-kernel performs NO server-side approver validation, so an
|
|
11725
|
+
// unauthorized tap is laundered into a real grant. The card is delivered
|
|
11726
|
+
// to the agent's chat(s) — for a forum/group agent that is every member.
|
|
11727
|
+
// Refuse callbacks from anyone outside `access.allowFrom`, BEFORE the
|
|
11728
|
+
// approval_consume nonce burn. Strict `access.allowFrom` — identical to
|
|
11729
|
+
// the drvpick:/op:/vd:/vg:/vra:/vrs:/vrd: families; the absence of this
|
|
11730
|
+
// check (not a deliberate exemption) was the vulnerability.
|
|
11504
11731
|
if (data.startsWith('apv:')) {
|
|
11732
|
+
const access = loadAccess()
|
|
11733
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
11734
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
11735
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' })
|
|
11736
|
+
return
|
|
11737
|
+
}
|
|
11505
11738
|
const { handleApprovalCallback } = await import('./approval-callback.js')
|
|
11506
11739
|
await handleApprovalCallback(ctx, data)
|
|
11507
11740
|
return
|
|
@@ -11512,10 +11745,11 @@ bot.on('callback_query:data', async ctx => {
|
|
|
11512
11745
|
// grant writes an allow_always kernel decision at
|
|
11513
11746
|
// doc:gdrive:folder/<id>/** and edits the card to a confirmation.
|
|
11514
11747
|
//
|
|
11515
|
-
// Auth gate: the picker grant is an OPERATOR action
|
|
11516
|
-
// `
|
|
11517
|
-
//
|
|
11518
|
-
// `access.allowFrom`. Without this, a group member
|
|
11748
|
+
// Auth gate: the picker grant is an OPERATOR action — same strict
|
|
11749
|
+
// `access.allowFrom` check as every other callback family (`op:`/
|
|
11750
|
+
// `vd:`/`vg:`/`apv:` since sec WS7-F1, …). Refuse callbacks from
|
|
11751
|
+
// anyone outside `access.allowFrom`. Without this, a group member
|
|
11752
|
+
// who isn't in
|
|
11519
11753
|
// the operator allowlist could still tap [✅ Allow "<folder>"] on
|
|
11520
11754
|
// a card that landed in the group and write an `allow_always`
|
|
11521
11755
|
// decision attributed to themselves.
|
|
@@ -13433,6 +13667,13 @@ void (async () => {
|
|
|
13433
13667
|
// sendMessage round-trip) sees an in-flight emit. See #489.
|
|
13434
13668
|
bootCardPending = true
|
|
13435
13669
|
try {
|
|
13670
|
+
// PR C: mirror the bridge-reconnect path — surface
|
|
13671
|
+
// a recent terminal update_apply outcome with claim
|
|
13672
|
+
// dedupe so it doesn't render twice if bridge
|
|
13673
|
+
// re-connects within the lookback window.
|
|
13674
|
+
const updateOutcomeLine = (() => {
|
|
13675
|
+
try { return maybeRenderUpdateAnnouncement() ?? undefined } catch { return undefined }
|
|
13676
|
+
})()
|
|
13436
13677
|
const handle = await startBootCard(chatId, threadId, botApiForCard, {
|
|
13437
13678
|
agentName: agentDisplayName,
|
|
13438
13679
|
agentSlug,
|
|
@@ -13443,8 +13684,10 @@ void (async () => {
|
|
|
13443
13684
|
restartAgeMs: markerAgeMs,
|
|
13444
13685
|
restartReasonDetail: cleanMarker?.reason,
|
|
13445
13686
|
loadAccounts: () => loadAccountsForBootCard(agentSlug),
|
|
13687
|
+
probeQuotaViaBroker: (t) => probeQuotaForBootCard(agentSlug, t),
|
|
13446
13688
|
tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
|
|
13447
13689
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
13690
|
+
...(updateOutcomeLine ? { updateOutcomeLine } : {}),
|
|
13448
13691
|
}, ackMsgId)
|
|
13449
13692
|
activeBootCard = handle
|
|
13450
13693
|
} catch (err) {
|