switchroom 0.16.28 → 0.16.38
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/agent-scheduler/index.js +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60648 -59800
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1293 -97
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +13 -4
|
@@ -142,7 +142,8 @@ export type GatewayToClient =
|
|
|
142
142
|
| ScheduleRestartResult
|
|
143
143
|
| DriveApprovalPostedEvent
|
|
144
144
|
| Ms365ApprovalPostedEvent
|
|
145
|
-
| ConfigApprovalResolvedEvent
|
|
145
|
+
| ConfigApprovalResolvedEvent
|
|
146
|
+
| RolloutStatusPostedEvent;
|
|
146
147
|
|
|
147
148
|
// === Bridge (Client) -> Gateway messages ===
|
|
148
149
|
|
|
@@ -493,6 +494,67 @@ export interface PostSkillProposalMessage {
|
|
|
493
494
|
draft: Record<string, string>;
|
|
494
495
|
}
|
|
495
496
|
|
|
497
|
+
/**
|
|
498
|
+
* #2726 Part 1 — hostd asks the caller agent's gateway to POST one ordinary
|
|
499
|
+
* operator-DM message narrating a rollout's terminal outcome. This is a NORMAL
|
|
500
|
+
* message, NOT a pinned card and NOT a bespoke widget — the framework speaking
|
|
501
|
+
* a plain progress line in the chat, which keeps it clear of
|
|
502
|
+
* `chat-is-the-single-source-of-truth` (in-chat narration, not a parallel
|
|
503
|
+
* pinned mirror).
|
|
504
|
+
*
|
|
505
|
+
* The gateway posts `text` to the operator chat (`allowFrom[0]`) and — for the
|
|
506
|
+
* Part 2 narration surface — replies with a `rollout_status_posted` event
|
|
507
|
+
* carrying the message_id so hostd can EDIT it as later phases arrive. Part 1
|
|
508
|
+
* uses only the terminal post and ignores the reply (fire-and-forget).
|
|
509
|
+
*
|
|
510
|
+
* Trust model: same as request_config_approval — the gateway socket lives in
|
|
511
|
+
* the agent container; hostd reaches it via the per-agent state-dir bind mount.
|
|
512
|
+
* `agentName` is validated server-side; the chat target is the gateway's OWN
|
|
513
|
+
* operator, never a caller-supplied chat.
|
|
514
|
+
*/
|
|
515
|
+
export interface RolloutStatusPostMessage {
|
|
516
|
+
type: "rollout_status_post";
|
|
517
|
+
/** hostd request_id of the roll (binds the surface to a real request). */
|
|
518
|
+
requestId: string;
|
|
519
|
+
/** The admin agent whose gateway relays the message (the rollout caller). */
|
|
520
|
+
agentName: string;
|
|
521
|
+
/** Fully-rendered message body. */
|
|
522
|
+
text: string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* #2726 Part 2 — hostd asks the gateway to EDIT the rollout status message it
|
|
527
|
+
* previously posted (identified by `messageId`, returned in the
|
|
528
|
+
* `rollout_status_posted` reply). Best-effort, fire-and-forget: an edit failure
|
|
529
|
+
* (incl. Telegram 429) is handled gateway-side and never surfaced back toward
|
|
530
|
+
* the roll.
|
|
531
|
+
*/
|
|
532
|
+
export interface RolloutStatusEditMessage {
|
|
533
|
+
type: "rollout_status_edit";
|
|
534
|
+
requestId: string;
|
|
535
|
+
agentName: string;
|
|
536
|
+
/** message_id of the status message to edit (from rollout_status_posted). */
|
|
537
|
+
messageId: number;
|
|
538
|
+
/** New fully-rendered body. */
|
|
539
|
+
text: string;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* #2726 — gateway → hostd reply after a `rollout_status_post` was posted, so
|
|
544
|
+
* hostd learns the message_id to EDIT for subsequent phases. `ok:false` when
|
|
545
|
+
* the post failed (hostd then simply won't edit — the durable log + terminal
|
|
546
|
+
* push remain the record).
|
|
547
|
+
*/
|
|
548
|
+
export interface RolloutStatusPostedEvent {
|
|
549
|
+
type: "rollout_status_posted";
|
|
550
|
+
requestId: string;
|
|
551
|
+
ok: boolean;
|
|
552
|
+
/** Telegram message_id of the posted status message (present when ok). */
|
|
553
|
+
messageId?: number;
|
|
554
|
+
/** Diagnostic detail on failure. */
|
|
555
|
+
reason?: string;
|
|
556
|
+
}
|
|
557
|
+
|
|
496
558
|
export type ClientToGateway =
|
|
497
559
|
| RegisterMessage
|
|
498
560
|
| ToolCallMessage
|
|
@@ -510,4 +572,6 @@ export type ClientToGateway =
|
|
|
510
572
|
| RequestConfigFinalizeMessage
|
|
511
573
|
| QuotaWallDetectedMessage
|
|
512
574
|
| SendOutboundMessage
|
|
513
|
-
| PostSkillProposalMessage
|
|
575
|
+
| PostSkillProposalMessage
|
|
576
|
+
| RolloutStatusPostMessage
|
|
577
|
+
| RolloutStatusEditMessage;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { renameSync, unlinkSync } from "fs";
|
|
1
|
+
import { renameSync, unlinkSync, chmodSync } from "fs";
|
|
2
2
|
import type {
|
|
3
3
|
ClientToGateway,
|
|
4
4
|
GatewayToClient,
|
|
@@ -15,6 +15,8 @@ import type {
|
|
|
15
15
|
RequestConfigFinalizeMessage,
|
|
16
16
|
RequestDriveApprovalMessage,
|
|
17
17
|
RequestMs365ApprovalMessage,
|
|
18
|
+
RolloutStatusPostMessage,
|
|
19
|
+
RolloutStatusEditMessage,
|
|
18
20
|
ScheduleRestartMessage,
|
|
19
21
|
SessionEventForward,
|
|
20
22
|
ToolCallMessage,
|
|
@@ -113,6 +115,25 @@ export interface IpcServerOptions {
|
|
|
113
115
|
client: IpcClient,
|
|
114
116
|
msg: RequestConfigFinalizeMessage,
|
|
115
117
|
) => Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* #2726 Part 1 — hostd asks the gateway to POST one ordinary operator-DM
|
|
120
|
+
* message narrating a rollout's terminal outcome (Part 1) or its first phase
|
|
121
|
+
* (Part 2). Handler posts to the agent's own operator chat and — for Part 2 —
|
|
122
|
+
* replies with a `rollout_status_posted` event carrying the message_id.
|
|
123
|
+
* Optional: gateways without the hostd integration ignore it.
|
|
124
|
+
*/
|
|
125
|
+
onRolloutStatusPost?: (
|
|
126
|
+
client: IpcClient,
|
|
127
|
+
msg: RolloutStatusPostMessage,
|
|
128
|
+
) => Promise<void>;
|
|
129
|
+
/**
|
|
130
|
+
* #2726 Part 2 — hostd asks the gateway to EDIT a previously-posted rollout
|
|
131
|
+
* status message as later phases arrive. Best-effort, no reply. Optional.
|
|
132
|
+
*/
|
|
133
|
+
onRolloutStatusEdit?: (
|
|
134
|
+
client: IpcClient,
|
|
135
|
+
msg: RolloutStatusEditMessage,
|
|
136
|
+
) => void;
|
|
116
137
|
log?: (msg: string) => void;
|
|
117
138
|
/**
|
|
118
139
|
* How long (in ms) to wait without a heartbeat before force-closing the
|
|
@@ -381,6 +402,33 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
381
402
|
|| (m.ttlMs as number) < 0)) return false;
|
|
382
403
|
return true;
|
|
383
404
|
}
|
|
405
|
+
case "rollout_status_post": {
|
|
406
|
+
// #2726 Part 1 — hostd-initiated terminal ping / status post. Wire shape
|
|
407
|
+
// only; the gateway handler fences the chat to the agent's own operator.
|
|
408
|
+
if (typeof m.requestId !== "string"
|
|
409
|
+
|| (m.requestId as string).length === 0
|
|
410
|
+
|| (m.requestId as string).length > 64) return false;
|
|
411
|
+
if (typeof m.agentName !== "string"
|
|
412
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
413
|
+
if (typeof m.text !== "string"
|
|
414
|
+
|| (m.text as string).length === 0
|
|
415
|
+
|| (m.text as string).length > RICH_MESSAGE_MAX_CHARS) return false;
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
case "rollout_status_edit": {
|
|
419
|
+
// #2726 Part 2 — hostd-initiated status-message edit.
|
|
420
|
+
if (typeof m.requestId !== "string"
|
|
421
|
+
|| (m.requestId as string).length === 0
|
|
422
|
+
|| (m.requestId as string).length > 64) return false;
|
|
423
|
+
if (typeof m.agentName !== "string"
|
|
424
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
425
|
+
if (typeof m.messageId !== "number"
|
|
426
|
+
|| !Number.isInteger(m.messageId as number)) return false;
|
|
427
|
+
if (typeof m.text !== "string"
|
|
428
|
+
|| (m.text as string).length === 0
|
|
429
|
+
|| (m.text as string).length > RICH_MESSAGE_MAX_CHARS) return false;
|
|
430
|
+
return true;
|
|
431
|
+
}
|
|
384
432
|
default:
|
|
385
433
|
return false;
|
|
386
434
|
}
|
|
@@ -406,6 +454,8 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
406
454
|
onRequestMs365Approval,
|
|
407
455
|
onRequestConfigApproval,
|
|
408
456
|
onRequestConfigFinalize,
|
|
457
|
+
onRolloutStatusPost,
|
|
458
|
+
onRolloutStatusEdit,
|
|
409
459
|
log = () => {},
|
|
410
460
|
heartbeatTimeoutMs = 30_000,
|
|
411
461
|
} = options;
|
|
@@ -637,6 +687,42 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
637
687
|
}
|
|
638
688
|
// No reply expected.
|
|
639
689
|
break;
|
|
690
|
+
case "rollout_status_post":
|
|
691
|
+
if (onRolloutStatusPost) {
|
|
692
|
+
onRolloutStatusPost(client, msg as RolloutStatusPostMessage).catch(
|
|
693
|
+
(err) => {
|
|
694
|
+
log(
|
|
695
|
+
`rollout_status_post handler threw (client=${client.id}): ${(err as Error).message}`,
|
|
696
|
+
);
|
|
697
|
+
// Best-effort failure reply so hostd's Part 2 renderer knows the
|
|
698
|
+
// post failed (it just won't edit). Part 1 ignores the reply.
|
|
699
|
+
try {
|
|
700
|
+
client.send({
|
|
701
|
+
type: "rollout_status_posted",
|
|
702
|
+
requestId: (msg as RolloutStatusPostMessage).requestId,
|
|
703
|
+
ok: false,
|
|
704
|
+
reason: `gateway handler error: ${(err as Error).message}`,
|
|
705
|
+
});
|
|
706
|
+
} catch {
|
|
707
|
+
/* best effort */
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
// No reply required when unwired — hostd falls back to the durable log.
|
|
713
|
+
break;
|
|
714
|
+
case "rollout_status_edit":
|
|
715
|
+
if (onRolloutStatusEdit) {
|
|
716
|
+
try {
|
|
717
|
+
onRolloutStatusEdit(client, msg as RolloutStatusEditMessage);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
log(
|
|
720
|
+
`rollout_status_edit handler threw (client=${client.id}): ${(err as Error).message}`,
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// Fire-and-forget; no reply.
|
|
725
|
+
break;
|
|
640
726
|
case "update_placeholder":
|
|
641
727
|
// Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
|
|
642
728
|
// Soft-accepted so recall.py keeps working without modifying
|
|
@@ -786,6 +872,10 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
786
872
|
},
|
|
787
873
|
});
|
|
788
874
|
|
|
875
|
+
// Allow the web container (uid=1000, operator) to inject prompts via
|
|
876
|
+
// injectInbound without needing root — the socket is inside the per-agent
|
|
877
|
+
// state directory which is already operator-accessible.
|
|
878
|
+
try { chmodSync(socketPath, 0o666); } catch { /* best-effort */ }
|
|
789
879
|
log(`listening on ${socketPath}`);
|
|
790
880
|
|
|
791
881
|
// ─── Heartbeat watchdog (issue #71) ─────────────────────────────────────
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* agent to `vault_request_access` for that key rather than failing opaquely.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { escapeMarkdown } from '../format.js'
|
|
18
19
|
import {
|
|
19
20
|
getViaBrokerStructured,
|
|
20
21
|
putViaBroker,
|
|
@@ -30,10 +31,6 @@ export type LinearAuthDeadReason = 'no_bundle' | 'revoked'
|
|
|
30
31
|
/** Minimal GFM-markdown escape (#2669). Kept local so the message builder is
|
|
31
32
|
* self-contained + unit-testable without reaching into a gateway-only
|
|
32
33
|
* escaper (the bug that shipped the first cut of this alert). */
|
|
33
|
-
function escapeMarkdownMin(s: string): string {
|
|
34
|
-
return s.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
35
|
-
}
|
|
36
|
-
|
|
37
34
|
/**
|
|
38
35
|
* Build the operator-facing Telegram alert (GFM markdown) for an un-healable
|
|
39
36
|
* Linear auth failure. Pure + self-escaping so it can be unit-tested directly.
|
|
@@ -42,7 +39,7 @@ function escapeMarkdownMin(s: string): string {
|
|
|
42
39
|
export function buildLinearAuthDeadMessage(agent: string, reason: LinearAuthDeadReason): string {
|
|
43
40
|
// Inside `code` spans the agent slug is literal (no escaping); in prose it
|
|
44
41
|
// is markdown-escaped.
|
|
45
|
-
const aEsc =
|
|
42
|
+
const aEsc = escapeMarkdown(agent)
|
|
46
43
|
const why =
|
|
47
44
|
reason === 'no_bundle'
|
|
48
45
|
? `no refresh credentials are stored (\`linear/${agent}/oauth\` is missing), so its daily-expiring token can't renew`
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision for what a turn_end does to the ending turn's delivery
|
|
3
|
+
* obligation (#2624).
|
|
4
|
+
*
|
|
5
|
+
* Two branches, both must be pinned by tests:
|
|
6
|
+
*
|
|
7
|
+
* - **Normal reply** — the turn either delivered a genuine final answer
|
|
8
|
+
* (`finalAnswerDelivered`) OR the model explicitly called reply
|
|
9
|
+
* (`replyCalled`, true even for a short LiteLLM sr-* `reply("OK",
|
|
10
|
+
* {disable_notification:true})` that `isFinalAnswerReply` demoted to
|
|
11
|
+
* non-final). Either way the obligation is satisfied → close it at
|
|
12
|
+
* turn_end.
|
|
13
|
+
*
|
|
14
|
+
* - **Ack-then-ghost / no-reply** — the turn ended with no reply at all
|
|
15
|
+
* (no ack, no answer). The obligation must NOT close at turn_end; it
|
|
16
|
+
* stays open and the idle sweep later ends it via the silence_fallback
|
|
17
|
+
* path. At turn_end we only stamp the grace clock (`note-ended`).
|
|
18
|
+
*
|
|
19
|
+
* Returns the action the caller applies to the ledger. Pure — no ledger
|
|
20
|
+
* mutation, no module state.
|
|
21
|
+
*/
|
|
22
|
+
export function decideObligationTurnEnd(
|
|
23
|
+
finalAnswerDelivered: boolean,
|
|
24
|
+
replyCalled: boolean,
|
|
25
|
+
): 'close' | 'note-ended' {
|
|
26
|
+
return finalAnswerDelivered || replyCalled ? 'close' : 'note-ended'
|
|
27
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence store for in-flight permission card references.
|
|
3
|
+
*
|
|
4
|
+
* Problem: `pendingPermissions` is in-memory. When the gateway restarts
|
|
5
|
+
* (gateway crash OR container restart), all entries are lost. Old permission
|
|
6
|
+
* cards in Telegram keep their [Allow][Deny] inline keyboard buttons.
|
|
7
|
+
* When the operator taps one, they get STALE_TAP_NOTICE ("already resolved
|
|
8
|
+
* (timed out)"), which is misleading — the request wasn't resolved, it was
|
|
9
|
+
* lost. Operators interpret this as "my approval didn't work."
|
|
10
|
+
*
|
|
11
|
+
* Fix: persist the (chatId, messageId) references for every posted card to a
|
|
12
|
+
* JSON file in STATE_DIR. On each resolution (allow/deny/TTL-expire), remove
|
|
13
|
+
* the entry. On gateway boot, load any surviving entries (they were never
|
|
14
|
+
* resolved — the gateway crashed) and strip their inline keyboards + add a
|
|
15
|
+
* "gateway restarted" footer so the operator sees a clear explanation instead
|
|
16
|
+
* of a tappable-but-dead button.
|
|
17
|
+
*
|
|
18
|
+
* File format: JSON array of PersistedPermCard objects. Kept small (one file,
|
|
19
|
+
* written synchronously to avoid interleaving on concurrent card posts).
|
|
20
|
+
* Production rate: a few cards per permission request; file stays tiny.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
export interface PersistedPermCard {
|
|
27
|
+
requestId: string
|
|
28
|
+
chatId: string
|
|
29
|
+
messageId: number
|
|
30
|
+
startedAt: number
|
|
31
|
+
toolName: string
|
|
32
|
+
cardText: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PermissionCardStore {
|
|
36
|
+
/** Record a newly-posted card. Idempotent on requestId+messageId. */
|
|
37
|
+
add(entry: PersistedPermCard): void
|
|
38
|
+
/** Remove all entries for this request (resolved — allow, deny, TTL). */
|
|
39
|
+
remove(requestId: string): void
|
|
40
|
+
/** Return all persisted entries (for boot-time stale-strip sweep). */
|
|
41
|
+
loadAll(): PersistedPermCard[]
|
|
42
|
+
/** Delete the backing file entirely (after boot sweep completes). */
|
|
43
|
+
clear(): void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createPermissionCardStore(stateDir: string): PermissionCardStore {
|
|
47
|
+
const filePath = join(stateDir, 'pending-perm-cards.json')
|
|
48
|
+
|
|
49
|
+
function read(): PersistedPermCard[] {
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(filePath, 'utf-8')
|
|
52
|
+
const parsed = JSON.parse(raw)
|
|
53
|
+
return Array.isArray(parsed) ? (parsed as PersistedPermCard[]) : []
|
|
54
|
+
} catch {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function write(entries: PersistedPermCard[]): void {
|
|
60
|
+
try {
|
|
61
|
+
writeFileSync(filePath, JSON.stringify(entries), { encoding: 'utf-8', mode: 0o600 })
|
|
62
|
+
} catch (err) {
|
|
63
|
+
process.stderr.write(
|
|
64
|
+
`telegram gateway: permission-card-store write failed: ${(err as Error).message}\n`,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
add(entry) {
|
|
71
|
+
const entries = read()
|
|
72
|
+
// Replace existing entry for same (requestId, messageId) if present
|
|
73
|
+
const idx = entries.findIndex(
|
|
74
|
+
e => e.requestId === entry.requestId && e.messageId === entry.messageId,
|
|
75
|
+
)
|
|
76
|
+
if (idx >= 0) {
|
|
77
|
+
entries[idx] = entry
|
|
78
|
+
} else {
|
|
79
|
+
entries.push(entry)
|
|
80
|
+
}
|
|
81
|
+
write(entries)
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
remove(requestId) {
|
|
85
|
+
const entries = read()
|
|
86
|
+
const filtered = entries.filter(e => e.requestId !== requestId)
|
|
87
|
+
if (filtered.length !== entries.length) {
|
|
88
|
+
write(filtered)
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
loadAll() {
|
|
93
|
+
return read()
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
clear() {
|
|
97
|
+
try {
|
|
98
|
+
unlinkSync(filePath)
|
|
99
|
+
} catch {
|
|
100
|
+
// File may not exist — that's fine
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -71,17 +71,36 @@ export function isRecentTimeoutDuplicate(
|
|
|
71
71
|
|
|
72
72
|
// ─── Bug 2 — per-tool TTL + timed-out card hygiene + stale-tap honesty ──────
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
export const
|
|
74
|
+
/** Fallback operator approval-card lifetime when nothing is configured. */
|
|
75
|
+
export const PERMISSION_TTL_DEFAULT_MS = 60 * 60_000
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the operator approval-card lifetime (ms) from the environment.
|
|
79
|
+
* Threaded from config as `channels.telegram.approval_timeout_minutes` →
|
|
80
|
+
* `SWITCHROOM_TG_APPROVAL_TIMEOUT_MS` (see scaffold.ts `channelsToEnv`). A
|
|
81
|
+
* blank/garbage value falls back to the 60-min default; `0` (or negative) is
|
|
82
|
+
* clamped to the default so a card can never be born already-expired.
|
|
83
|
+
*/
|
|
84
|
+
export function approvalTtlMs(
|
|
85
|
+
env: Record<string, string | undefined> = process.env,
|
|
86
|
+
): number {
|
|
87
|
+
const raw = env.SWITCHROOM_TG_APPROVAL_TIMEOUT_MS
|
|
88
|
+
if (raw === undefined || raw.trim() === '') return PERMISSION_TTL_DEFAULT_MS
|
|
89
|
+
const n = Number(raw)
|
|
90
|
+
if (!Number.isFinite(n) || n <= 0) return PERMISSION_TTL_DEFAULT_MS
|
|
91
|
+
return Math.floor(n)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Default operator approval-card lifetime (config-driven, 60 min default). */
|
|
95
|
+
export const PERMISSION_TTL_MS = approvalTtlMs()
|
|
76
96
|
|
|
77
97
|
/**
|
|
78
98
|
* hostd gated fleet-mutation verbs (rollout / update_apply / agent_* /
|
|
79
99
|
* config_propose_edit) surface an OPERATOR approval card and demand a
|
|
80
|
-
* human-scale decision window
|
|
81
|
-
*
|
|
82
|
-
* the 10-min default.
|
|
100
|
+
* human-scale decision window. The `mcp__hostd__*` family gets at least
|
|
101
|
+
* 30 min; if the configured approval window is longer, it wins.
|
|
83
102
|
*/
|
|
84
|
-
export const HOSTD_PERMISSION_TTL_MS = 30 * 60_000
|
|
103
|
+
export const HOSTD_PERMISSION_TTL_MS = Math.max(30 * 60_000, PERMISSION_TTL_MS)
|
|
85
104
|
|
|
86
105
|
/** Per-tool approval-card TTL. */
|
|
87
106
|
export function ttlForTool(toolName: string | undefined): number {
|