switchroom 0.13.9 → 0.13.10
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/dist/cli/switchroom.js +38 -14
- package/dist/host-control/main.js +222 -7
- package/examples/switchroom.yaml +25 -7
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +514 -143
- package/telegram-plugin/gateway/config-approval-handler.test.ts +246 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +284 -0
- package/telegram-plugin/gateway/gateway.ts +206 -21
- package/telegram-plugin/gateway/ipc-protocol.ts +72 -2
- package/telegram-plugin/gateway/ipc-server.ts +101 -0
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +103 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +69 -0
- package/telegram-plugin/subagent-watcher.ts +39 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +105 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +61 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +67 -1
- package/telegram-plugin/uat/scenarios/jtbd-subagent-handback-dm.test.ts +95 -0
- package/profiles/default/CLAUDE.md +0 -193
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway-side `request_config_approval` handler tests (#1623).
|
|
3
|
+
*
|
|
4
|
+
* Focuses on the load-bearing transitions, not exhaustive plumbing:
|
|
5
|
+
* - Happy path: card posted, callback resolved, finalize edits.
|
|
6
|
+
* - Cross-agent rejection.
|
|
7
|
+
* - Double-tap is a no-op (second `resolvePendingConfigApproval`
|
|
8
|
+
* returns false and does not send a second verdict).
|
|
9
|
+
* - Timeout fires `verdict: "timeout"` automatically.
|
|
10
|
+
* - parseConfigApprovalCallback parses + rejects malformed input.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
14
|
+
import {
|
|
15
|
+
buildConfigApprovalCardBody,
|
|
16
|
+
handleRequestConfigApproval,
|
|
17
|
+
handleRequestConfigFinalize,
|
|
18
|
+
parseConfigApprovalCallback,
|
|
19
|
+
resolvePendingConfigApproval,
|
|
20
|
+
_resetPendingConfigApprovalsForTest,
|
|
21
|
+
_peekPendingConfigApprovalForTest,
|
|
22
|
+
} from "./config-approval-handler.js";
|
|
23
|
+
import type { RequestConfigApprovalMessage } from "./ipc-protocol.js";
|
|
24
|
+
|
|
25
|
+
const baseMsg: RequestConfigApprovalMessage = {
|
|
26
|
+
type: "request_config_approval",
|
|
27
|
+
requestId: "req-1",
|
|
28
|
+
agentName: "klanker",
|
|
29
|
+
reason: "tighten doctor schedule",
|
|
30
|
+
unifiedDiff: "--- a/x\n+++ b/x\n@@\n-a\n+b\n",
|
|
31
|
+
timeoutMs: 60_000,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function fakeDeps(overrides: Partial<Parameters<typeof handleRequestConfigApproval>[2]> = {}) {
|
|
35
|
+
const sent: Array<{ type: string; [k: string]: unknown }> = [];
|
|
36
|
+
const client = {
|
|
37
|
+
send: (m: { type: string; [k: string]: unknown }) => {
|
|
38
|
+
sent.push(m);
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const editCalls: Array<{
|
|
42
|
+
chatId: number | string;
|
|
43
|
+
messageId: number;
|
|
44
|
+
text: string;
|
|
45
|
+
}> = [];
|
|
46
|
+
const deps = {
|
|
47
|
+
agentName: "klanker",
|
|
48
|
+
loadTargetChat: () => ({ chatId: 42 }),
|
|
49
|
+
postCard: vi.fn(async () => ({ messageId: 1001 })),
|
|
50
|
+
buildKeyboard: () => ({ inline_keyboard: [] }),
|
|
51
|
+
editCard: async (a: { chatId: number | string; messageId: number; text: string }) => {
|
|
52
|
+
editCalls.push(a);
|
|
53
|
+
},
|
|
54
|
+
log: () => {},
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
return { client, sent, deps, editCalls };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
_resetPendingConfigApprovalsForTest();
|
|
62
|
+
});
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
_resetPendingConfigApprovalsForTest();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("buildConfigApprovalCardBody", () => {
|
|
68
|
+
it("HTML-escapes the diff body so `<` / `&` can't break out of the <pre> block", () => {
|
|
69
|
+
const body = buildConfigApprovalCardBody({
|
|
70
|
+
agentName: "klanker",
|
|
71
|
+
reason: "<script>",
|
|
72
|
+
unifiedDiff: "a & b <c>",
|
|
73
|
+
});
|
|
74
|
+
expect(body).toContain("<script>");
|
|
75
|
+
expect(body).toContain("a & b <c>");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("handleRequestConfigApproval", () => {
|
|
80
|
+
it("posts the card, registers a pending entry, and stays open until resolved", async () => {
|
|
81
|
+
const { client, deps } = fakeDeps();
|
|
82
|
+
await handleRequestConfigApproval(client, baseMsg, deps);
|
|
83
|
+
expect(deps.postCard).toHaveBeenCalledTimes(1);
|
|
84
|
+
const pending = _peekPendingConfigApprovalForTest("req-1");
|
|
85
|
+
expect(pending).toBeDefined();
|
|
86
|
+
expect(pending!.messageId).toBe(1001);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects a cross-agent request without posting a card", async () => {
|
|
90
|
+
const { client, sent, deps } = fakeDeps();
|
|
91
|
+
await handleRequestConfigApproval(
|
|
92
|
+
client,
|
|
93
|
+
{ ...baseMsg, agentName: "evilpeer" },
|
|
94
|
+
deps,
|
|
95
|
+
);
|
|
96
|
+
expect(deps.postCard).not.toHaveBeenCalled();
|
|
97
|
+
expect(sent).toEqual([
|
|
98
|
+
{
|
|
99
|
+
type: "config_approval_resolved",
|
|
100
|
+
requestId: "req-1",
|
|
101
|
+
verdict: "deny",
|
|
102
|
+
reason: expect.stringContaining("gateway serves 'klanker'"),
|
|
103
|
+
},
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("rejects when no target chat is paired", async () => {
|
|
108
|
+
const { client, sent, deps } = fakeDeps({ loadTargetChat: () => null });
|
|
109
|
+
await handleRequestConfigApproval(client, baseMsg, deps);
|
|
110
|
+
expect(sent[0]!.verdict).toBe("deny");
|
|
111
|
+
expect(sent[0]!.reason).toMatch(/not paired/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects when postCard fails (Telegram down)", async () => {
|
|
115
|
+
const { client, sent, deps } = fakeDeps({
|
|
116
|
+
postCard: vi.fn(async () => null),
|
|
117
|
+
});
|
|
118
|
+
await handleRequestConfigApproval(client, baseMsg, deps);
|
|
119
|
+
expect(sent[0]!.verdict).toBe("deny");
|
|
120
|
+
expect(sent[0]!.reason).toMatch(/sendMessage failed/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("resolvePendingConfigApproval — double-tap and verdict propagation", () => {
|
|
125
|
+
it("first tap resolves; second tap is a no-op", async () => {
|
|
126
|
+
const { client, sent, deps, editCalls } = fakeDeps();
|
|
127
|
+
await handleRequestConfigApproval(client, baseMsg, deps);
|
|
128
|
+
const first = await resolvePendingConfigApproval("req-1", "approve", deps);
|
|
129
|
+
expect(first).toBe(true);
|
|
130
|
+
const second = await resolvePendingConfigApproval("req-1", "deny", deps);
|
|
131
|
+
expect(second).toBe(false);
|
|
132
|
+
// Only one verdict crossed the wire to hostd.
|
|
133
|
+
const verdicts = sent.filter((s) => s.type === "config_approval_resolved");
|
|
134
|
+
expect(verdicts.length).toBe(1);
|
|
135
|
+
expect(verdicts[0]!.verdict).toBe("approve");
|
|
136
|
+
// Card edited once to the interim 'Applying' state.
|
|
137
|
+
expect(editCalls.length).toBe(1);
|
|
138
|
+
expect(editCalls[0]!.text).toMatch(/Applying/);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns false when no entry exists (unknown requestId)", async () => {
|
|
142
|
+
const { deps } = fakeDeps();
|
|
143
|
+
const r = await resolvePendingConfigApproval("unknown", "approve", deps);
|
|
144
|
+
expect(r).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("timeout path", () => {
|
|
149
|
+
it("auto-fires verdict 'timeout' after timeoutMs and edits card to '⏱ Expired'", async () => {
|
|
150
|
+
vi.useFakeTimers();
|
|
151
|
+
try {
|
|
152
|
+
const { client, sent, deps, editCalls } = fakeDeps();
|
|
153
|
+
await handleRequestConfigApproval(
|
|
154
|
+
client,
|
|
155
|
+
{ ...baseMsg, timeoutMs: 1000 },
|
|
156
|
+
deps,
|
|
157
|
+
);
|
|
158
|
+
vi.advanceTimersByTime(1500);
|
|
159
|
+
// Allow microtasks scheduled inside the timer callback to flush.
|
|
160
|
+
// NOT vi.runAllTimersAsync() — that is unimplemented under bun's
|
|
161
|
+
// vitest-compat shim and this suite also runs under `bun test`
|
|
162
|
+
// (CLAUDE.md § "Import the right runner"). advanceTimersByTime
|
|
163
|
+
// already fired the timer synchronously; we only need to drain
|
|
164
|
+
// the microtask queue the timer's async callback scheduled.
|
|
165
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
166
|
+
const verdicts = sent.filter((s) => s.type === "config_approval_resolved");
|
|
167
|
+
expect(verdicts.length).toBe(1);
|
|
168
|
+
expect(verdicts[0]!.verdict).toBe("timeout");
|
|
169
|
+
expect(editCalls[0]!.text).toMatch(/Expired/);
|
|
170
|
+
} finally {
|
|
171
|
+
vi.useRealTimers();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("handleRequestConfigFinalize", () => {
|
|
177
|
+
it("edits the card to '✅ Applied' on success", async () => {
|
|
178
|
+
const { client, deps, editCalls } = fakeDeps();
|
|
179
|
+
await handleRequestConfigApproval(client, baseMsg, deps);
|
|
180
|
+
await resolvePendingConfigApproval("req-1", "approve", deps);
|
|
181
|
+
await handleRequestConfigFinalize(
|
|
182
|
+
client,
|
|
183
|
+
{
|
|
184
|
+
type: "request_config_finalize",
|
|
185
|
+
requestId: "req-1",
|
|
186
|
+
outcome: "applied",
|
|
187
|
+
},
|
|
188
|
+
deps,
|
|
189
|
+
);
|
|
190
|
+
const last = editCalls[editCalls.length - 1]!;
|
|
191
|
+
expect(last.text).toMatch(/Applied/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("edits to '⚠️ Reconcile failed; rolled back' with detail", async () => {
|
|
195
|
+
const { client, deps, editCalls } = fakeDeps();
|
|
196
|
+
await handleRequestConfigApproval(client, baseMsg, deps);
|
|
197
|
+
await resolvePendingConfigApproval("req-1", "approve", deps);
|
|
198
|
+
await handleRequestConfigFinalize(
|
|
199
|
+
client,
|
|
200
|
+
{
|
|
201
|
+
type: "request_config_finalize",
|
|
202
|
+
requestId: "req-1",
|
|
203
|
+
outcome: "reconcile_failed_rolled_back",
|
|
204
|
+
detail: "rolled back successfully",
|
|
205
|
+
},
|
|
206
|
+
deps,
|
|
207
|
+
);
|
|
208
|
+
const last = editCalls[editCalls.length - 1]!;
|
|
209
|
+
expect(last.text).toMatch(/Reconcile failed/);
|
|
210
|
+
expect(last.text).toMatch(/rolled back successfully/);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("is a no-op when no pending entry exists for the requestId", async () => {
|
|
214
|
+
const { client, deps, editCalls } = fakeDeps();
|
|
215
|
+
await handleRequestConfigFinalize(
|
|
216
|
+
client,
|
|
217
|
+
{
|
|
218
|
+
type: "request_config_finalize",
|
|
219
|
+
requestId: "missing",
|
|
220
|
+
outcome: "applied",
|
|
221
|
+
},
|
|
222
|
+
deps,
|
|
223
|
+
);
|
|
224
|
+
expect(editCalls.length).toBe(0);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("parseConfigApprovalCallback", () => {
|
|
229
|
+
it("parses well-formed callbacks", () => {
|
|
230
|
+
expect(parseConfigApprovalCallback("cfg:abc:approve")).toEqual({
|
|
231
|
+
requestId: "abc",
|
|
232
|
+
choice: "approve",
|
|
233
|
+
});
|
|
234
|
+
expect(parseConfigApprovalCallback("cfg:deadbeef:deny")).toEqual({
|
|
235
|
+
requestId: "deadbeef",
|
|
236
|
+
choice: "deny",
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("rejects malformed input", () => {
|
|
241
|
+
expect(parseConfigApprovalCallback("apv:abc:once")).toBeNull();
|
|
242
|
+
expect(parseConfigApprovalCallback("cfg:")).toBeNull();
|
|
243
|
+
expect(parseConfigApprovalCallback("cfg:abc:bogus")).toBeNull();
|
|
244
|
+
expect(parseConfigApprovalCallback("cfg::approve")).toBeNull();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// hostd config-edit approval handler (#1623 / RFC §3.3): posts an approval
|
|
2
|
+
// card, resolves the verdict back to hostd over IPC, and flips the card to
|
|
3
|
+
// a terminal state on finalize.
|
|
4
|
+
|
|
5
|
+
import type { IpcClient } from "./ipc-server.js";
|
|
6
|
+
import type {
|
|
7
|
+
RequestConfigApprovalMessage,
|
|
8
|
+
RequestConfigFinalizeMessage,
|
|
9
|
+
} from "./ipc-protocol.js";
|
|
10
|
+
|
|
11
|
+
/** Pending approval state — in-memory only (no SQLite per RFC §3.4). */
|
|
12
|
+
interface PendingConfigApproval {
|
|
13
|
+
requestId: string;
|
|
14
|
+
client: Pick<IpcClient, "send">;
|
|
15
|
+
chatId: number | string;
|
|
16
|
+
threadId?: number;
|
|
17
|
+
messageId: number;
|
|
18
|
+
/** node:Timeout — set when the timer is armed; cleared once the
|
|
19
|
+
* request resolves (approve/deny/timeout) to make resolve idempotent. */
|
|
20
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
21
|
+
/** Has a verdict already been sent? Guards against double-tap. */
|
|
22
|
+
resolved: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pending = new Map<string, PendingConfigApproval>();
|
|
26
|
+
|
|
27
|
+
// Injected deps — gateway.ts wires these from the existing surface.
|
|
28
|
+
|
|
29
|
+
export interface ConfigApprovalHandlerDeps {
|
|
30
|
+
/** This gateway's agent name — cross-agent requests rejected. */
|
|
31
|
+
agentName: string;
|
|
32
|
+
/** Operator's primary chat for the card. Returns null if not paired. */
|
|
33
|
+
loadTargetChat: () => {
|
|
34
|
+
chatId: number | string;
|
|
35
|
+
threadId?: number;
|
|
36
|
+
} | null;
|
|
37
|
+
/** Post the Telegram card. Returns the posted message id on success. */
|
|
38
|
+
postCard: (args: {
|
|
39
|
+
chatId: number | string;
|
|
40
|
+
threadId?: number;
|
|
41
|
+
text: string;
|
|
42
|
+
/** grammy InlineKeyboard, passed through verbatim. */
|
|
43
|
+
replyMarkup: unknown;
|
|
44
|
+
}) => Promise<{ messageId: number } | null>;
|
|
45
|
+
/** Build the inline keyboard with [✅ Approve] [🚫 Deny] buttons. */
|
|
46
|
+
buildKeyboard: (requestId: string) => unknown;
|
|
47
|
+
/** Edit a posted card to a new body. Best-effort — failures logged. */
|
|
48
|
+
editCard: (args: {
|
|
49
|
+
chatId: number | string;
|
|
50
|
+
messageId: number;
|
|
51
|
+
text: string;
|
|
52
|
+
}) => Promise<void>;
|
|
53
|
+
log?: (msg: string) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the card body (HTML). Renders the full diff in a `<pre>`
|
|
58
|
+
* code block — Telegram caps messages at 4096 chars, so very large
|
|
59
|
+
* diffs may be truncated by the API; the validator already caps
|
|
60
|
+
* unified_diff at ~63 KiB so practical fleet edits fit comfortably.
|
|
61
|
+
*/
|
|
62
|
+
export function buildConfigApprovalCardBody(args: {
|
|
63
|
+
agentName: string;
|
|
64
|
+
reason: string;
|
|
65
|
+
unifiedDiff: string;
|
|
66
|
+
}): string {
|
|
67
|
+
const esc = (s: string) =>
|
|
68
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
69
|
+
return (
|
|
70
|
+
`🛠 <b>Config edit proposed</b>\n` +
|
|
71
|
+
`Agent: <code>${esc(args.agentName)}</code>\n` +
|
|
72
|
+
`Reason: ${esc(args.reason)}\n\n` +
|
|
73
|
+
`<pre>${esc(args.unifiedDiff)}</pre>`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Top-level handler — called by the IPC dispatcher.
|
|
79
|
+
*/
|
|
80
|
+
export async function handleRequestConfigApproval(
|
|
81
|
+
client: Pick<IpcClient, "send">,
|
|
82
|
+
msg: RequestConfigApprovalMessage,
|
|
83
|
+
deps: ConfigApprovalHandlerDeps,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
const reply = (
|
|
86
|
+
verdict: "approve" | "deny" | "timeout",
|
|
87
|
+
reason?: string,
|
|
88
|
+
) => {
|
|
89
|
+
try {
|
|
90
|
+
client.send({
|
|
91
|
+
type: "config_approval_resolved",
|
|
92
|
+
requestId: msg.requestId,
|
|
93
|
+
verdict,
|
|
94
|
+
...(reason ? { reason } : {}),
|
|
95
|
+
});
|
|
96
|
+
} catch (err) {
|
|
97
|
+
deps.log?.(
|
|
98
|
+
`config_approval_resolved send failed (requestId=${msg.requestId}): ${(err as Error).message}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (msg.agentName !== deps.agentName) {
|
|
104
|
+
reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const target = deps.loadTargetChat();
|
|
109
|
+
if (target === null) {
|
|
110
|
+
reply("deny", "no target chat available — operator not paired?");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const body = buildConfigApprovalCardBody({
|
|
115
|
+
agentName: msg.agentName,
|
|
116
|
+
reason: msg.reason,
|
|
117
|
+
unifiedDiff: msg.unifiedDiff,
|
|
118
|
+
});
|
|
119
|
+
const replyMarkup = deps.buildKeyboard(msg.requestId);
|
|
120
|
+
|
|
121
|
+
const posted = await deps.postCard({
|
|
122
|
+
chatId: target.chatId,
|
|
123
|
+
...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
|
|
124
|
+
text: body,
|
|
125
|
+
replyMarkup,
|
|
126
|
+
});
|
|
127
|
+
if (posted === null) {
|
|
128
|
+
reply("deny", "Telegram sendMessage failed");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const entry: PendingConfigApproval = {
|
|
133
|
+
requestId: msg.requestId,
|
|
134
|
+
client,
|
|
135
|
+
chatId: target.chatId,
|
|
136
|
+
...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
|
|
137
|
+
messageId: posted.messageId,
|
|
138
|
+
timer: null,
|
|
139
|
+
resolved: false,
|
|
140
|
+
};
|
|
141
|
+
entry.timer = setTimeout(() => {
|
|
142
|
+
void resolvePendingConfigApproval(msg.requestId, "timeout", deps).catch(
|
|
143
|
+
(err) =>
|
|
144
|
+
deps.log?.(
|
|
145
|
+
`config approval timeout handler threw (requestId=${msg.requestId}): ${(err as Error).message}`,
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
}, msg.timeoutMs);
|
|
149
|
+
pending.set(msg.requestId, entry);
|
|
150
|
+
|
|
151
|
+
deps.log?.(
|
|
152
|
+
`config_approval_posted requestId=${msg.requestId} agent=${msg.agentName} messageId=${posted.messageId}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Called by the `cfg:` callback dispatcher in gateway.ts on an
|
|
158
|
+
* operator tap, by the per-request timer on expiry, OR by the
|
|
159
|
+
* finalize path defensively before edit. Sends a single
|
|
160
|
+
* `config_approval_resolved` reply over the original client
|
|
161
|
+
* connection and edits the card to the interim state. Double-tap
|
|
162
|
+
* safe — subsequent calls for the same requestId are no-ops.
|
|
163
|
+
*
|
|
164
|
+
* Returns true if THIS call resolved the request (first call wins),
|
|
165
|
+
* false if it was already resolved.
|
|
166
|
+
*/
|
|
167
|
+
export async function resolvePendingConfigApproval(
|
|
168
|
+
requestId: string,
|
|
169
|
+
verdict: "approve" | "deny" | "timeout",
|
|
170
|
+
deps: Pick<ConfigApprovalHandlerDeps, "editCard" | "log">,
|
|
171
|
+
): Promise<boolean> {
|
|
172
|
+
const entry = pending.get(requestId);
|
|
173
|
+
if (!entry || entry.resolved) return false;
|
|
174
|
+
entry.resolved = true;
|
|
175
|
+
if (entry.timer !== null) {
|
|
176
|
+
clearTimeout(entry.timer);
|
|
177
|
+
entry.timer = null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Send the verdict back to hostd. Best effort — if the IPC
|
|
181
|
+
// connection has dropped, hostd's own timeout will fire.
|
|
182
|
+
try {
|
|
183
|
+
entry.client.send({
|
|
184
|
+
type: "config_approval_resolved",
|
|
185
|
+
requestId,
|
|
186
|
+
verdict,
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
deps.log?.(
|
|
190
|
+
`config_approval_resolved send failed (requestId=${requestId}): ${(err as Error).message}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Edit the card to an interim/terminal state.
|
|
195
|
+
const interim =
|
|
196
|
+
verdict === "approve"
|
|
197
|
+
? "👀 <b>Applying…</b>"
|
|
198
|
+
: verdict === "deny"
|
|
199
|
+
? "🚫 <b>Denied</b>"
|
|
200
|
+
: "⏱ <b>Expired</b>";
|
|
201
|
+
try {
|
|
202
|
+
await deps.editCard({
|
|
203
|
+
chatId: entry.chatId,
|
|
204
|
+
messageId: entry.messageId,
|
|
205
|
+
text: interim,
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
deps.log?.(
|
|
209
|
+
`config approval card edit failed (requestId=${requestId}): ${(err as Error).message}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** IPC `request_config_finalize` handler — edits the card to the terminal outcome. */
|
|
216
|
+
export async function handleRequestConfigFinalize(
|
|
217
|
+
_client: Pick<IpcClient, "send">,
|
|
218
|
+
msg: RequestConfigFinalizeMessage,
|
|
219
|
+
deps: Pick<ConfigApprovalHandlerDeps, "editCard" | "log">,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
const entry = pending.get(msg.requestId);
|
|
222
|
+
if (!entry) {
|
|
223
|
+
deps.log?.(
|
|
224
|
+
`config_finalize: no pending entry for requestId=${msg.requestId} (likely already cleaned up)`,
|
|
225
|
+
);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
// Clean up the pending entry — finalize is the terminal transition.
|
|
229
|
+
pending.delete(msg.requestId);
|
|
230
|
+
|
|
231
|
+
const body =
|
|
232
|
+
msg.outcome === "applied"
|
|
233
|
+
? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`
|
|
234
|
+
: `⚠️ <b>Reconcile failed; rolled back</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`;
|
|
235
|
+
try {
|
|
236
|
+
await deps.editCard({
|
|
237
|
+
chatId: entry.chatId,
|
|
238
|
+
messageId: entry.messageId,
|
|
239
|
+
text: body,
|
|
240
|
+
});
|
|
241
|
+
} catch (err) {
|
|
242
|
+
deps.log?.(
|
|
243
|
+
`config finalize card edit failed (requestId=${msg.requestId}): ${(err as Error).message}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function escapeHtml(s: string): string {
|
|
249
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Test-only: clear the in-memory pending map between cases.
|
|
253
|
+
export function _resetPendingConfigApprovalsForTest(): void {
|
|
254
|
+
for (const entry of pending.values()) {
|
|
255
|
+
if (entry.timer !== null) clearTimeout(entry.timer);
|
|
256
|
+
}
|
|
257
|
+
pending.clear();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Test-only: peek at a pending entry.
|
|
261
|
+
export function _peekPendingConfigApprovalForTest(
|
|
262
|
+
requestId: string,
|
|
263
|
+
): Readonly<PendingConfigApproval> | undefined {
|
|
264
|
+
return pending.get(requestId);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Parse `cfg:<requestId>:<choice>` callback data. Returns null on
|
|
269
|
+
* malformed input. The callback handler in gateway.ts uses this +
|
|
270
|
+
* resolvePendingConfigApproval to drive the tap → resolve flow.
|
|
271
|
+
*/
|
|
272
|
+
export function parseConfigApprovalCallback(
|
|
273
|
+
data: string,
|
|
274
|
+
): { requestId: string; choice: "approve" | "deny" } | null {
|
|
275
|
+
if (!data.startsWith("cfg:")) return null;
|
|
276
|
+
const rest = data.slice(4);
|
|
277
|
+
const colon = rest.lastIndexOf(":");
|
|
278
|
+
if (colon < 0) return null;
|
|
279
|
+
const requestId = rest.slice(0, colon);
|
|
280
|
+
const choice = rest.slice(colon + 1);
|
|
281
|
+
if (requestId.length === 0 || requestId.length > 64) return null;
|
|
282
|
+
if (choice !== "approve" && choice !== "deny") return null;
|
|
283
|
+
return { requestId, choice };
|
|
284
|
+
}
|