switchroom 0.5.0 → 0.7.8
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 +142 -121
- package/bin/autoaccept.exp +29 -6
- package/dist/agent-scheduler/index.js +12261 -0
- package/dist/cli/autoaccept-poll.js +10 -0
- package/dist/cli/switchroom.js +27250 -25324
- package/dist/vault/approvals/kernel-server.js +12709 -0
- package/dist/vault/broker/server.js +15724 -0
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +133 -0
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/profiles/default/CLAUDE.md +3 -3
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
- package/skills/docx/VENDORED.md +1 -1
- package/skills/mcp-builder/VENDORED.md +1 -1
- package/skills/pdf/VENDORED.md +1 -1
- package/skills/pptx/VENDORED.md +1 -1
- package/skills/skill-creator/VENDORED.md +1 -1
- package/skills/switchroom-architecture/SKILL.md +8 -7
- package/skills/switchroom-cli/SKILL.md +23 -15
- package/skills/switchroom-health/SKILL.md +7 -7
- package/skills/switchroom-install/SKILL.md +36 -39
- package/skills/switchroom-manage/SKILL.md +4 -4
- package/skills/switchroom-status/SKILL.md +1 -1
- package/skills/webapp-testing/VENDORED.md +1 -1
- package/skills/xlsx/VENDORED.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
- package/telegram-plugin/admin-commands/index.ts +71 -0
- package/telegram-plugin/ask-user.ts +1 -0
- package/telegram-plugin/card-event-log.ts +138 -0
- package/telegram-plugin/dist/bridge/bridge.js +178 -31
- package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
- package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
- package/telegram-plugin/dist/server.js +202 -40
- package/telegram-plugin/fleet-state.ts +25 -10
- package/telegram-plugin/foreman/foreman.ts +38 -3
- package/telegram-plugin/gateway/approval-callback.ts +126 -0
- package/telegram-plugin/gateway/approval-card.test.ts +90 -0
- package/telegram-plugin/gateway/approval-card.ts +127 -0
- package/telegram-plugin/gateway/approvals-commands.ts +126 -0
- package/telegram-plugin/gateway/boot-card.ts +31 -6
- package/telegram-plugin/gateway/boot-probes.ts +503 -72
- package/telegram-plugin/gateway/gateway.ts +822 -94
- package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
- package/telegram-plugin/gateway/ipc-server.ts +35 -0
- package/telegram-plugin/gateway/startup-mutex.ts +110 -2
- package/telegram-plugin/hooks/hooks.json +19 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
- package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
- package/telegram-plugin/package.json +4 -1
- package/telegram-plugin/plugin-logger.ts +20 -1
- package/telegram-plugin/progress-card-driver.ts +202 -13
- package/telegram-plugin/progress-card.ts +2 -2
- package/telegram-plugin/quota-check.ts +1 -0
- package/telegram-plugin/registry/subagents-schema.ts +37 -0
- package/telegram-plugin/registry/subagents.test.ts +64 -0
- package/telegram-plugin/session-tail.ts +58 -5
- package/telegram-plugin/shared/bot-runtime.ts +48 -2
- package/telegram-plugin/subagent-watcher.ts +139 -7
- package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
- package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
- package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
- package/telegram-plugin/tests/boot-probes.test.ts +558 -0
- package/telegram-plugin/tests/card-event-log.test.ts +145 -0
- package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
- package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
- package/telegram-plugin/tests/quota-check.test.ts +37 -1
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
- package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
- package/telegram-plugin/tests/welcome-text.test.ts +57 -0
- package/telegram-plugin/tool-label-sidecar.ts +140 -0
- package/telegram-plugin/tool-labels.ts +55 -0
- package/telegram-plugin/two-zone-card.ts +27 -7
- package/telegram-plugin/uat/SETUP.md +160 -0
- package/telegram-plugin/uat/assertions.ts +140 -0
- package/telegram-plugin/uat/driver.ts +174 -0
- package/telegram-plugin/uat/harness.ts +161 -0
- package/telegram-plugin/uat/login.ts +134 -0
- package/telegram-plugin/uat/port-allocator.ts +71 -0
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
- package/telegram-plugin/welcome-text.ts +44 -2
- package/bin/bridge-watchdog.sh +0 -967
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic `apv:` callback handler (RFC B §6.1, §8).
|
|
3
|
+
*
|
|
4
|
+
* Owns the post-tap state-machine for every approval card, regardless of
|
|
5
|
+
* which surface (secret/vault/MCP) opened the request:
|
|
6
|
+
*
|
|
7
|
+
* 1. Parse the callback_data via parseApprovalCallback.
|
|
8
|
+
* 2. Round-trip to the broker: approval_consume.
|
|
9
|
+
* - If consumed=false (already-tapped, expired, unknown): show a
|
|
10
|
+
* toast and edit the card to its post-tap text.
|
|
11
|
+
* 3. On `deny`: approval_record(granted=false).
|
|
12
|
+
* On `once`: approval_record(granted=true, ttl_ms=undefined) — the
|
|
13
|
+
* record exists for /approvals revoke targeting; it's harmless if
|
|
14
|
+
* the agent only ever calls approval_lookup once.
|
|
15
|
+
* On `always`: approval_record(granted=true, ttl_ms=null).
|
|
16
|
+
* On `ttl:1h|24h|7d`: approval_record(granted=true, ttl_ms=<n>).
|
|
17
|
+
* 4. Edit the card text in-place to reflect the decision; remove the
|
|
18
|
+
* inline keyboard so the buttons can't be re-tapped.
|
|
19
|
+
*
|
|
20
|
+
* The agent that opened the request is responsible for short-polling
|
|
21
|
+
* approval_lookup (RFC §10) to discover the outcome and proceed.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Context } from "grammy";
|
|
25
|
+
import { parseApprovalCallback, ttlMsFromToken } from "./approval-card.js";
|
|
26
|
+
import {
|
|
27
|
+
approvalConsume,
|
|
28
|
+
approvalRecord,
|
|
29
|
+
} from "../../src/vault/approvals/client.js";
|
|
30
|
+
import type { ApprovalDecisionMode } from "../../src/vault/approvals/schema.js";
|
|
31
|
+
|
|
32
|
+
export async function handleApprovalCallback(
|
|
33
|
+
ctx: Context,
|
|
34
|
+
data: string,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const parsed = parseApprovalCallback(data);
|
|
37
|
+
if (parsed === null) {
|
|
38
|
+
await ctx.answerCallbackQuery({ text: "malformed approval callback" });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const consumed = await approvalConsume(parsed.request_id);
|
|
43
|
+
if (consumed === null) {
|
|
44
|
+
await ctx.answerCallbackQuery({ text: "approval kernel unreachable" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!consumed.consumed) {
|
|
48
|
+
// Single-use enforcement: someone already tapped, or the nonce
|
|
49
|
+
// expired/unknown. Match the RFC §8.1 wording.
|
|
50
|
+
await ctx.answerCallbackQuery({ text: "this prompt expired" });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Compute decision + ttl from the choice variant.
|
|
55
|
+
let decision: ApprovalDecisionMode;
|
|
56
|
+
let granted: boolean;
|
|
57
|
+
let ttl_ms: number | null = null;
|
|
58
|
+
let displayMode: string;
|
|
59
|
+
switch (parsed.choice.kind) {
|
|
60
|
+
case "deny":
|
|
61
|
+
decision = "deny";
|
|
62
|
+
granted = false;
|
|
63
|
+
displayMode = "denied";
|
|
64
|
+
break;
|
|
65
|
+
case "once":
|
|
66
|
+
decision = "allow_once";
|
|
67
|
+
granted = true;
|
|
68
|
+
// No expiry — recorded as a one-shot grant; the agent calls
|
|
69
|
+
// approval_lookup at most once, then proceeds. /approvals revoke
|
|
70
|
+
// can still target the row by id.
|
|
71
|
+
displayMode = "granted once";
|
|
72
|
+
break;
|
|
73
|
+
case "always":
|
|
74
|
+
decision = "allow_always";
|
|
75
|
+
granted = true;
|
|
76
|
+
displayMode = "granted always";
|
|
77
|
+
break;
|
|
78
|
+
case "ttl": {
|
|
79
|
+
decision = "allow_ttl";
|
|
80
|
+
granted = true;
|
|
81
|
+
const ms = ttlMsFromToken(parsed.choice.param);
|
|
82
|
+
if (ms === null) {
|
|
83
|
+
await ctx.answerCallbackQuery({ text: "bad ttl token" });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
ttl_ms = ms;
|
|
87
|
+
displayMode = `granted for ${parsed.choice.param}`;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const granted_by_user_id = ctx.from?.id ?? 0;
|
|
93
|
+
// Approver set at decision time = the chat that received the card. We
|
|
94
|
+
// store the singleton for now; the gateway-side approver-set lookup
|
|
95
|
+
// (drift detection input) will widen this in the per-callsite wire-up
|
|
96
|
+
// when each surface migrates and starts passing access.allowFrom.
|
|
97
|
+
const approver_set = [String(granted_by_user_id)];
|
|
98
|
+
|
|
99
|
+
const decision_id = await approvalRecord({
|
|
100
|
+
request_id: parsed.request_id,
|
|
101
|
+
decision,
|
|
102
|
+
approver_set,
|
|
103
|
+
granted_by_user_id,
|
|
104
|
+
ttl_ms,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (decision_id === null) {
|
|
108
|
+
await ctx.answerCallbackQuery({ text: "kernel record failed" });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Edit the original card to its post-tap state and drop the keyboard.
|
|
113
|
+
const icon = granted ? "✅" : "🚫";
|
|
114
|
+
const newBody =
|
|
115
|
+
`${icon} ${displayMode}` +
|
|
116
|
+
(granted
|
|
117
|
+
? ` · /approvals revoke <code>${decision_id}</code>`
|
|
118
|
+
: "");
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await ctx.editMessageText(newBody, { parse_mode: "HTML", reply_markup: undefined });
|
|
122
|
+
} catch {
|
|
123
|
+
// Best-effort: card may have been edited or deleted under us.
|
|
124
|
+
}
|
|
125
|
+
await ctx.answerCallbackQuery({ text: granted ? "Approved" : "Denied" });
|
|
126
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the approval card primitive (RFC B §8).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
buildApprovalCard,
|
|
8
|
+
parseApprovalCallback,
|
|
9
|
+
ttlMsFromToken,
|
|
10
|
+
} from "./approval-card.js";
|
|
11
|
+
|
|
12
|
+
describe("approval card", () => {
|
|
13
|
+
it("renders the pristine card with default buttons", () => {
|
|
14
|
+
const card = buildApprovalCard({
|
|
15
|
+
request_id: "a3f1b9c2",
|
|
16
|
+
agent: "klanker",
|
|
17
|
+
scope_humanized: "secret:OPENAI_API_KEY",
|
|
18
|
+
why: "needs to call OpenAI",
|
|
19
|
+
});
|
|
20
|
+
expect(card.text).toContain("klanker");
|
|
21
|
+
expect(card.text).toContain("secret:OPENAI_API_KEY");
|
|
22
|
+
expect(card.text).toContain("needs to call OpenAI");
|
|
23
|
+
|
|
24
|
+
// Validate the inline_keyboard structure carries our four callback shapes
|
|
25
|
+
const flat = card.reply_markup.inline_keyboard.flat();
|
|
26
|
+
const datas = flat.map((b) => ("callback_data" in b ? b.callback_data : ""));
|
|
27
|
+
expect(datas).toContain("apv:a3f1b9c2:once");
|
|
28
|
+
expect(datas).toContain("apv:a3f1b9c2:deny");
|
|
29
|
+
expect(datas).toContain("apv:a3f1b9c2:always");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("respects offer_always=false and offer_ttl=true", () => {
|
|
33
|
+
const card = buildApprovalCard({
|
|
34
|
+
request_id: "a3f1b9c2",
|
|
35
|
+
agent: "k",
|
|
36
|
+
scope_humanized: "x",
|
|
37
|
+
offer_always: false,
|
|
38
|
+
offer_ttl: true,
|
|
39
|
+
});
|
|
40
|
+
const datas = card.reply_markup.inline_keyboard
|
|
41
|
+
.flat()
|
|
42
|
+
.map((b) => ("callback_data" in b ? b.callback_data : ""));
|
|
43
|
+
expect(datas).not.toContain("apv:a3f1b9c2:always");
|
|
44
|
+
expect(datas).toContain("apv:a3f1b9c2:ttl:1h");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("escapes HTML metacharacters in agent and scope", () => {
|
|
48
|
+
const card = buildApprovalCard({
|
|
49
|
+
request_id: "deadbeef",
|
|
50
|
+
agent: "<script>",
|
|
51
|
+
scope_humanized: "a&b",
|
|
52
|
+
});
|
|
53
|
+
expect(card.text).not.toContain("<script>");
|
|
54
|
+
expect(card.text).toContain("<script>");
|
|
55
|
+
expect(card.text).toContain("a&b");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("parseApprovalCallback", () => {
|
|
60
|
+
it("parses every choice variant", () => {
|
|
61
|
+
expect(parseApprovalCallback("apv:a3f1b9c2:once"))
|
|
62
|
+
.toEqual({ request_id: "a3f1b9c2", choice: { kind: "once" } });
|
|
63
|
+
expect(parseApprovalCallback("apv:a3f1b9c2:always"))
|
|
64
|
+
.toEqual({ request_id: "a3f1b9c2", choice: { kind: "always" } });
|
|
65
|
+
expect(parseApprovalCallback("apv:a3f1b9c2:deny"))
|
|
66
|
+
.toEqual({ request_id: "a3f1b9c2", choice: { kind: "deny" } });
|
|
67
|
+
expect(parseApprovalCallback("apv:a3f1b9c2:ttl:1h"))
|
|
68
|
+
.toEqual({ request_id: "a3f1b9c2", choice: { kind: "ttl", param: "1h" } });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("rejects malformed prefixes and bad ids", () => {
|
|
72
|
+
expect(parseApprovalCallback("perm:more:abc")).toBeNull();
|
|
73
|
+
expect(parseApprovalCallback("apv:NOTHEX12:once")).toBeNull();
|
|
74
|
+
expect(parseApprovalCallback("apv:a3f1b9c2:bogus")).toBeNull();
|
|
75
|
+
expect(parseApprovalCallback("apv:a3f1b9c2:ttl")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("ttlMsFromToken", () => {
|
|
80
|
+
it("parses h and d units", () => {
|
|
81
|
+
expect(ttlMsFromToken("1h")).toBe(60 * 60 * 1000);
|
|
82
|
+
expect(ttlMsFromToken("24h")).toBe(24 * 60 * 60 * 1000);
|
|
83
|
+
expect(ttlMsFromToken("7d")).toBe(7 * 24 * 60 * 60 * 1000);
|
|
84
|
+
});
|
|
85
|
+
it("rejects garbage", () => {
|
|
86
|
+
expect(ttlMsFromToken("0h")).toBeNull();
|
|
87
|
+
expect(ttlMsFromToken("1m")).toBeNull();
|
|
88
|
+
expect(ttlMsFromToken("h")).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Telegram approval card primitive (RFC B §8).
|
|
3
|
+
*
|
|
4
|
+
* One shape, every surface (secrets / vault grants / MCP tools). Builds
|
|
5
|
+
* the inline keyboard with [Allow once] [Allow always] [Deny] (and an
|
|
6
|
+
* optional [⏱ For 1h] when ttl is offered) and the matching `apv:` callback
|
|
7
|
+
* data. The `apv:` namespace is reserved for the approval kernel; the
|
|
8
|
+
* gateway dispatch table routes those callbacks into kernel.consumeNonce
|
|
9
|
+
* + kernel.recordDecision.
|
|
10
|
+
*
|
|
11
|
+
* Callback wire format (RFC §6.1, fits Telegram's 64-byte cap):
|
|
12
|
+
* apv:<8-hex request_id>:<choice>[:<param>]
|
|
13
|
+
* choice = once | always | deny | ttl
|
|
14
|
+
* param = (for ttl) 1h | 24h | 7d
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { InlineKeyboard } from "grammy";
|
|
18
|
+
|
|
19
|
+
export interface ApprovalCardOptions {
|
|
20
|
+
request_id: string; // 8-hex from kernel.requestApproval
|
|
21
|
+
agent: string; // shown in the title
|
|
22
|
+
scope_humanized: string; // human-readable scope (resolver may patch later)
|
|
23
|
+
why?: string; // optional context paragraph
|
|
24
|
+
offer_always?: boolean; // hide [Allow always] when scope is too narrow to bind a rule
|
|
25
|
+
offer_ttl?: boolean; // show [⏱ For 1h] secondary button
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BuiltApprovalCard {
|
|
29
|
+
text: string;
|
|
30
|
+
reply_markup: InlineKeyboard;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the pristine approval card. Granted/denied/expired states are
|
|
35
|
+
* rendered by the gateway after the user taps — those use editMessageText
|
|
36
|
+
* with a fresh body, no buttons.
|
|
37
|
+
*/
|
|
38
|
+
export function buildApprovalCard(opts: ApprovalCardOptions): BuiltApprovalCard {
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
lines.push(`🔐 <b>${escapeHtml(opts.agent)}</b> wants approval`);
|
|
41
|
+
lines.push(`<code>${escapeHtml(opts.scope_humanized)}</code>`);
|
|
42
|
+
if (opts.why && opts.why.trim().length > 0) {
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push(escapeHtml(opts.why.trim()));
|
|
45
|
+
}
|
|
46
|
+
const text = lines.join("\n");
|
|
47
|
+
|
|
48
|
+
const kb = new InlineKeyboard()
|
|
49
|
+
.text("✅ Allow once", `apv:${opts.request_id}:once`)
|
|
50
|
+
.text("🚫 Deny", `apv:${opts.request_id}:deny`);
|
|
51
|
+
|
|
52
|
+
// Secondary row — Always + TTL when offered
|
|
53
|
+
const secondary: Array<[string, string]> = [];
|
|
54
|
+
if (opts.offer_always !== false) {
|
|
55
|
+
secondary.push(["🔁 Always", `apv:${opts.request_id}:always`]);
|
|
56
|
+
}
|
|
57
|
+
if (opts.offer_ttl === true) {
|
|
58
|
+
secondary.push(["⏱ For 1h", `apv:${opts.request_id}:ttl:1h`]);
|
|
59
|
+
}
|
|
60
|
+
if (secondary.length > 0) {
|
|
61
|
+
kb.row();
|
|
62
|
+
for (const [label, data] of secondary) {
|
|
63
|
+
kb.text(label, data);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { text, reply_markup: kb };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Parse an `apv:` callback. Returns null on a malformed string so the
|
|
72
|
+
* caller can fall through to whatever generic-callback handling exists.
|
|
73
|
+
*/
|
|
74
|
+
export type ApprovalChoice =
|
|
75
|
+
| { kind: "once" }
|
|
76
|
+
| { kind: "always" }
|
|
77
|
+
| { kind: "deny" }
|
|
78
|
+
| { kind: "ttl"; param: string };
|
|
79
|
+
|
|
80
|
+
export interface ParsedApprovalCallback {
|
|
81
|
+
request_id: string;
|
|
82
|
+
choice: ApprovalChoice;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseApprovalCallback(data: string): ParsedApprovalCallback | null {
|
|
86
|
+
if (!data.startsWith("apv:")) return null;
|
|
87
|
+
const parts = data.split(":");
|
|
88
|
+
// apv:<id>:<choice>[:<param>]
|
|
89
|
+
if (parts.length < 3) return null;
|
|
90
|
+
const request_id = parts[1];
|
|
91
|
+
const choiceStr = parts[2];
|
|
92
|
+
if (!/^[0-9a-f]{8}$/.test(request_id ?? "")) return null;
|
|
93
|
+
switch (choiceStr) {
|
|
94
|
+
case "once":
|
|
95
|
+
return { request_id: request_id as string, choice: { kind: "once" } };
|
|
96
|
+
case "always":
|
|
97
|
+
return { request_id: request_id as string, choice: { kind: "always" } };
|
|
98
|
+
case "deny":
|
|
99
|
+
return { request_id: request_id as string, choice: { kind: "deny" } };
|
|
100
|
+
case "ttl": {
|
|
101
|
+
const param = parts[3];
|
|
102
|
+
if (!param) return null;
|
|
103
|
+
return { request_id: request_id as string, choice: { kind: "ttl", param } };
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Map a TTL token like '1h' / '24h' / '7d' to milliseconds. */
|
|
111
|
+
export function ttlMsFromToken(token: string): number | null {
|
|
112
|
+
const m = /^(\d+)([hd])$/.exec(token);
|
|
113
|
+
if (!m) return null;
|
|
114
|
+
const n = parseInt(m[1] ?? "", 10);
|
|
115
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
116
|
+
const unit = m[2];
|
|
117
|
+
if (unit === "h") return n * 60 * 60 * 1000;
|
|
118
|
+
if (unit === "d") return n * 24 * 60 * 60 * 1000;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function escapeHtml(s: string): string {
|
|
123
|
+
return s
|
|
124
|
+
.replace(/&/g, "&")
|
|
125
|
+
.replace(/</g, "<")
|
|
126
|
+
.replace(/>/g, ">");
|
|
127
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/approvals` command surface (RFC B §9).
|
|
3
|
+
*
|
|
4
|
+
* Self-contained module that registers `/approvals list` and
|
|
5
|
+
* `/approvals revoke <id>` against a grammy Bot. Wired in from
|
|
6
|
+
* `gateway.ts` with one line:
|
|
7
|
+
*
|
|
8
|
+
* import { registerApprovalsCommands } from './approvals-commands.js'
|
|
9
|
+
* registerApprovalsCommands(bot, { allowFromCheck })
|
|
10
|
+
*
|
|
11
|
+
* Out of scope for this commit: `/approvals add` (grant wizard) and
|
|
12
|
+
* `/approvals stats` (RFC §9). Those are Phase 1.5 — straightforward to
|
|
13
|
+
* add on top of the same client. Tracked in the migration TODO inline.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Bot, Context } from "grammy";
|
|
17
|
+
import {
|
|
18
|
+
approvalList,
|
|
19
|
+
approvalRevoke,
|
|
20
|
+
} from "../../src/vault/approvals/client.js";
|
|
21
|
+
|
|
22
|
+
export interface RegisterApprovalsCommandsOpts {
|
|
23
|
+
/**
|
|
24
|
+
* Caller-provided gate that returns true if the message sender is
|
|
25
|
+
* permitted to run /approvals commands. Mirrors the existing pattern
|
|
26
|
+
* used by other administrative commands (e.g. /pending) — the gateway
|
|
27
|
+
* already has its `allowFrom` check; we don't duplicate the policy
|
|
28
|
+
* lookup here.
|
|
29
|
+
*/
|
|
30
|
+
isApprover: (ctx: Context) => boolean | Promise<boolean>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function registerApprovalsCommands(
|
|
34
|
+
bot: Bot,
|
|
35
|
+
opts: RegisterApprovalsCommandsOpts,
|
|
36
|
+
): void {
|
|
37
|
+
bot.command("approvals", async (ctx) => {
|
|
38
|
+
if (!(await opts.isApprover(ctx))) {
|
|
39
|
+
await ctx.reply("Not authorized to view approvals.", { reply_markup: undefined });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const args = (ctx.match ?? "").toString().trim().split(/\s+/).filter(Boolean);
|
|
44
|
+
const sub = args[0]?.toLowerCase();
|
|
45
|
+
|
|
46
|
+
// /approvals → list
|
|
47
|
+
if (sub === undefined || sub === "list") {
|
|
48
|
+
const agentFilter = sub === "list" ? args[1] : undefined;
|
|
49
|
+
const decisions = await approvalList(agentFilter);
|
|
50
|
+
if (decisions === null) {
|
|
51
|
+
await ctx.reply("Approval kernel unreachable (vault broker not running?).");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (decisions.length === 0) {
|
|
55
|
+
await ctx.reply(
|
|
56
|
+
agentFilter
|
|
57
|
+
? `No active approvals for <code>${escapeHtml(agentFilter)}</code>.`
|
|
58
|
+
: "No active approvals.",
|
|
59
|
+
{ parse_mode: "HTML" },
|
|
60
|
+
);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// Top-level summary by agent, mirroring the GitHub-style permissions UI
|
|
64
|
+
// referenced in RFC §9.
|
|
65
|
+
const byAgent = new Map<string, number>();
|
|
66
|
+
for (const d of decisions) byAgent.set(d.agent_unit, (byAgent.get(d.agent_unit) ?? 0) + 1);
|
|
67
|
+
const summary = Array.from(byAgent.entries())
|
|
68
|
+
.map(([a, n]) => `• <b>${escapeHtml(a)}</b>: ${n}`)
|
|
69
|
+
.join("\n");
|
|
70
|
+
const detail = decisions
|
|
71
|
+
.slice(0, 20)
|
|
72
|
+
.map((d) => {
|
|
73
|
+
const ttl =
|
|
74
|
+
d.ttl_expires_at === null
|
|
75
|
+
? "always"
|
|
76
|
+
: `until ${new Date(d.ttl_expires_at).toISOString().slice(0, 16).replace("T", " ")}`;
|
|
77
|
+
return (
|
|
78
|
+
`<code>${escapeHtml(d.id.slice(0, 8))}</code> ` +
|
|
79
|
+
`${escapeHtml(d.agent_unit)} → ` +
|
|
80
|
+
`<code>${escapeHtml(d.scope)}</code> ` +
|
|
81
|
+
`(${escapeHtml(d.action)}, ${ttl}) ` +
|
|
82
|
+
`· /approvals revoke ${escapeHtml(d.id)}`
|
|
83
|
+
);
|
|
84
|
+
})
|
|
85
|
+
.join("\n");
|
|
86
|
+
await ctx.reply(`<b>Active approvals</b>\n\n${summary}\n\n${detail}`, {
|
|
87
|
+
parse_mode: "HTML",
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// /approvals revoke <id>
|
|
93
|
+
if (sub === "revoke") {
|
|
94
|
+
const id = args[1];
|
|
95
|
+
if (!id) {
|
|
96
|
+
await ctx.reply("Usage: <code>/approvals revoke <id></code>", {
|
|
97
|
+
parse_mode: "HTML",
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const actor = ctx.from?.id?.toString() ?? "unknown";
|
|
102
|
+
const ok = await approvalRevoke(id, actor, "manual /approvals revoke");
|
|
103
|
+
if (ok === null) {
|
|
104
|
+
await ctx.reply("Approval kernel unreachable.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await ctx.reply(
|
|
108
|
+
ok ? `Revoked <code>${escapeHtml(id)}</code>.` : `No such active decision <code>${escapeHtml(id)}</code>.`,
|
|
109
|
+
{ parse_mode: "HTML" },
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// /approvals add and /approvals stats — TODO Phase 1.5
|
|
115
|
+
await ctx.reply(
|
|
116
|
+
`Unknown subcommand <code>${escapeHtml(sub)}</code>. ` +
|
|
117
|
+
`Use <code>/approvals list</code> or <code>/approvals revoke <id></code>. ` +
|
|
118
|
+
`(<code>add</code> and <code>stats</code> are coming in a follow-up.)`,
|
|
119
|
+
{ parse_mode: "HTML" },
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function escapeHtml(s: string): string {
|
|
125
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
126
|
+
}
|
|
@@ -41,7 +41,10 @@ import {
|
|
|
41
41
|
probeGateway,
|
|
42
42
|
probeQuota,
|
|
43
43
|
probeHindsight,
|
|
44
|
-
|
|
44
|
+
probeScheduler,
|
|
45
|
+
probeBroker,
|
|
46
|
+
probeKernel,
|
|
47
|
+
probeSkills,
|
|
45
48
|
watchAgentProcess,
|
|
46
49
|
AGENT_LIVE_WINDOW_MS,
|
|
47
50
|
AGENT_LIVE_POLL_INTERVAL_MS,
|
|
@@ -89,7 +92,16 @@ export function resolvePersonaName(
|
|
|
89
92
|
|
|
90
93
|
export type RestartReason = 'planned' | 'graceful' | 'crash' | 'fresh'
|
|
91
94
|
|
|
92
|
-
export type ProbeKey =
|
|
95
|
+
export type ProbeKey =
|
|
96
|
+
| 'account'
|
|
97
|
+
| 'agent'
|
|
98
|
+
| 'gateway'
|
|
99
|
+
| 'quota'
|
|
100
|
+
| 'hindsight'
|
|
101
|
+
| 'scheduler'
|
|
102
|
+
| 'broker'
|
|
103
|
+
| 'kernel'
|
|
104
|
+
| 'skills'
|
|
93
105
|
|
|
94
106
|
export type ProbeMap = Partial<Record<ProbeKey, ProbeResult | null>>
|
|
95
107
|
|
|
@@ -204,11 +216,15 @@ const PROBE_LABELS: Record<ProbeKey, string> = {
|
|
|
204
216
|
gateway: 'Gateway',
|
|
205
217
|
quota: 'Quota',
|
|
206
218
|
hindsight: 'Hindsight',
|
|
207
|
-
|
|
219
|
+
scheduler: 'Scheduler',
|
|
220
|
+
broker: 'Broker',
|
|
221
|
+
kernel: 'Kernel',
|
|
222
|
+
skills: 'Skills',
|
|
208
223
|
}
|
|
209
224
|
|
|
210
225
|
const PROBE_KEYS: ReadonlyArray<ProbeKey> = [
|
|
211
|
-
'account', 'agent', 'gateway', 'quota', 'hindsight',
|
|
226
|
+
'account', 'agent', 'gateway', 'quota', 'hindsight',
|
|
227
|
+
'scheduler', 'broker', 'kernel', 'skills',
|
|
212
228
|
]
|
|
213
229
|
|
|
214
230
|
const REASON_EMOJI: Record<RestartReason, string> = {
|
|
@@ -390,6 +406,11 @@ export interface RunProbesOpts {
|
|
|
390
406
|
/** When true, resolve the agent PID via cgroup walk instead of MainPID
|
|
391
407
|
* (which is the tmux server pid under tmux supervisor). */
|
|
392
408
|
tmuxSupervisor?: boolean
|
|
409
|
+
/** When true, the gateway is running inside an agent docker container.
|
|
410
|
+
* Probes that depend on systemctl (Agent, Crons) switch to /proc walks
|
|
411
|
+
* and externally-managed surface text instead of execing systemctl
|
|
412
|
+
* (which doesn't exist in the agent image). See `runtime-mode.ts`. */
|
|
413
|
+
dockerMode?: boolean
|
|
393
414
|
}
|
|
394
415
|
|
|
395
416
|
/** Run all six probes concurrently with their own per-probe timeouts.
|
|
@@ -404,11 +425,14 @@ export async function runAllProbes(opts: RunProbesOpts): Promise<ProbeMap> {
|
|
|
404
425
|
|
|
405
426
|
await Promise.allSettled([
|
|
406
427
|
probeAccount(opts.agentDir).then(r => { probes.account = r }),
|
|
407
|
-
probeAgentProcess(slug, { execFileImpl: opts.probeExecFileImpl, tmuxSupervisor: opts.tmuxSupervisor }).then(r => { probes.agent = r }),
|
|
428
|
+
probeAgentProcess(slug, { execFileImpl: opts.probeExecFileImpl, tmuxSupervisor: opts.tmuxSupervisor, dockerMode: opts.dockerMode }).then(r => { probes.agent = r }),
|
|
408
429
|
probeGateway(opts.gatewayInfo).then(r => { probes.gateway = r }),
|
|
409
430
|
probeQuota(claudeDir, opts.agentDir, opts.fetchImpl).then(r => { probes.quota = r }),
|
|
410
431
|
probeHindsight(opts.bankName, opts.fetchImpl).then(r => { probes.hindsight = r }),
|
|
411
|
-
|
|
432
|
+
probeScheduler(slug, { dockerMode: opts.dockerMode }).then(r => { probes.scheduler = r }),
|
|
433
|
+
probeBroker(undefined, { dockerMode: opts.dockerMode }).then(r => { probes.broker = r }),
|
|
434
|
+
probeKernel(undefined, { dockerMode: opts.dockerMode }).then(r => { probes.kernel = r }),
|
|
435
|
+
probeSkills(opts.agentDir).then(r => { probes.skills = r }),
|
|
412
436
|
])
|
|
413
437
|
|
|
414
438
|
return probes
|
|
@@ -530,6 +554,7 @@ export async function startBootCard(
|
|
|
530
554
|
sleepImpl: opts.agentLiveSleepImpl,
|
|
531
555
|
execFileImpl: opts.agentLiveExecFileImpl,
|
|
532
556
|
tmuxSupervisor: opts.tmuxSupervisor,
|
|
557
|
+
dockerMode: opts.dockerMode,
|
|
533
558
|
})
|
|
534
559
|
|
|
535
560
|
for await (const agentResult of watcher) {
|