switchroom 0.18.11 → 0.18.12
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 +29 -5
- package/dist/auth-broker/index.js +53 -13
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/notion-write-pretool.mjs +29 -5
- package/dist/cli/switchroom.js +2453 -1293
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +504 -100
- package/dist/vault/approvals/kernel-server.js +53 -13
- package/dist/vault/broker/server.js +162 -114
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +73 -1
- package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
- package/telegram-plugin/dist/server.js +74 -2
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/gateway/approval-hold.ts +583 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1482 -165
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/send-gate-degraded.test.ts +443 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +698 -0
- package/telegram-plugin/send-gate.ts +982 -0
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +102 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
|
@@ -46,6 +46,24 @@ function onPermissionRequestBody(): string {
|
|
|
46
46
|
return rest.slice(0, end)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Slice the body of `postPermissionCard` — THE card emitter.
|
|
51
|
+
*
|
|
52
|
+
* The send used to be inlined in `onPermissionRequest`. The #3084 follow-up
|
|
53
|
+
* extracted it here so it has two callers (the initial delivery, and the
|
|
54
|
+
* reaper's re-delivery of a card held through a flood ban). The routing
|
|
55
|
+
* contract these tests pin is unchanged; it just lives one function over, and
|
|
56
|
+
* now BOTH delivery paths inherit it — which is the point of the extraction.
|
|
57
|
+
*/
|
|
58
|
+
function postPermissionCardBody(): string {
|
|
59
|
+
const start = GATEWAY_SRC.indexOf('function postPermissionCard(')
|
|
60
|
+
expect(start).toBeGreaterThan(-1)
|
|
61
|
+
const rest = GATEWAY_SRC.slice(start)
|
|
62
|
+
const end = rest.indexOf('\nfunction ', 1)
|
|
63
|
+
expect(end).toBeGreaterThan(-1)
|
|
64
|
+
return rest.slice(0, end)
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
describe('permission card routing', () => {
|
|
50
68
|
it('the shared target helper exists', () => {
|
|
51
69
|
expect(
|
|
@@ -53,19 +71,26 @@ describe('permission card routing', () => {
|
|
|
53
71
|
).toBe(true)
|
|
54
72
|
})
|
|
55
73
|
|
|
56
|
-
it('the
|
|
57
|
-
expect(
|
|
74
|
+
it('the card emitter routes via resolvePermissionCardTargets()', () => {
|
|
75
|
+
expect(postPermissionCardBody()).toContain('resolvePermissionCardTargets()')
|
|
58
76
|
})
|
|
59
77
|
|
|
60
|
-
it('the
|
|
78
|
+
it('the card emitter no longer iterates access.allowFrom directly (the bug shape)', () => {
|
|
61
79
|
// The raw fan-out loop is what sent supergroup cards to operator DMs.
|
|
62
|
-
expect(
|
|
80
|
+
expect(postPermissionCardBody()).not.toMatch(
|
|
63
81
|
/for\s*\(\s*const\s+chat_id\s+of\s+access\.allowFrom\s*\)/,
|
|
64
82
|
)
|
|
65
83
|
})
|
|
66
84
|
|
|
67
85
|
it('the card send is wrapped in retryWithThreadFallback (stale-topic → thread-less, not a silent drop)', () => {
|
|
68
|
-
expect(
|
|
86
|
+
expect(postPermissionCardBody()).toContain('retryWithThreadFallback')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('the initial permission request still delivers through that one emitter', () => {
|
|
90
|
+
// Guards the extraction itself: onPermissionRequest must not regrow its own
|
|
91
|
+
// send path and drift from the re-delivery path.
|
|
92
|
+
expect(onPermissionRequestBody()).toContain('postPermissionCard(requestId, pendEntry)')
|
|
93
|
+
expect(onPermissionRequestBody()).not.toContain('retryWithThreadFallback')
|
|
69
94
|
})
|
|
70
95
|
|
|
71
96
|
it('the resume message uses the SAME helper, so card + resume cannot drift', () => {
|
|
@@ -45,15 +45,15 @@ describe('no-repeat-on-timeout wiring', () => {
|
|
|
45
45
|
// when Bug 2 added per-tool TTL + the timed-out keyboard-strip to this
|
|
46
46
|
// block — the signature-record line now sits further down but the wiring
|
|
47
47
|
// is intact.
|
|
48
|
-
const sweep = slice(GATEWAY, '
|
|
48
|
+
const sweep = slice(GATEWAY, 'sweepPermissionTtl({', 5000)
|
|
49
49
|
expect(sweep).toContain('timeoutDenyMessage(')
|
|
50
50
|
expect(sweep).toContain('permissionTimeoutSignatures.set(')
|
|
51
51
|
})
|
|
52
52
|
|
|
53
53
|
it('the TTL auto-deny strips the timed-out card keyboard (Bug 2)', () => {
|
|
54
|
-
const sweep = slice(GATEWAY, '
|
|
54
|
+
const sweep = slice(GATEWAY, 'sweepPermissionTtl({', 5000)
|
|
55
55
|
// Per-tool TTL + keyboard-strip are both wired into the sweep.
|
|
56
|
-
expect(sweep).toContain('ttlForTool
|
|
56
|
+
expect(sweep).toContain('ttlForTool')
|
|
57
57
|
expect(sweep).toContain('stripTimedOutPermissionCards(')
|
|
58
58
|
})
|
|
59
59
|
|
|
@@ -91,14 +91,15 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
|
|
|
91
91
|
// first interim reply. Without this, a new inbound delivered in that window
|
|
92
92
|
// displaces the approval context and orphans the pending MCP call.
|
|
93
93
|
it('turnInFlightForGate includes pendingPermissions.size > 0 on the legacy path', () => {
|
|
94
|
-
// span
|
|
95
|
-
|
|
94
|
+
// span 2600: the function gained a ~1100-char note (#3084 follow-up) documenting
|
|
95
|
+
// the one case with no timeout valve — a HELD approval.
|
|
96
|
+
const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2600)
|
|
96
97
|
// Both paths (legacy claudeBusyKeys + machine-authoritative) must include the check.
|
|
97
98
|
expect(fn).toMatch(/claudeBusyKeys\.size\s*>\s*0\s*\|\|\s*hasPendingApproval/)
|
|
98
99
|
})
|
|
99
100
|
|
|
100
101
|
it('turnInFlightForGate includes pendingPermissions.size > 0 on the machine path', () => {
|
|
101
|
-
const fn = slice(GATEWAY, 'function turnInFlightForGate()',
|
|
102
|
+
const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2700)
|
|
102
103
|
// probeGateParity(...) || hasPendingApproval — the inner arg contains its own
|
|
103
104
|
// parens (isMachineInTurn()), so match the two tokens independently.
|
|
104
105
|
expect(fn).toContain('probeGateParity(')
|
|
@@ -106,7 +107,7 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
|
|
|
106
107
|
})
|
|
107
108
|
|
|
108
109
|
it('hasPendingApproval reads pendingPermissions.size', () => {
|
|
109
|
-
const fn = slice(GATEWAY, 'function turnInFlightForGate()',
|
|
110
|
+
const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2600)
|
|
110
111
|
expect(fn).toMatch(/pendingPermissions\.size\s*>\s*0/)
|
|
111
112
|
})
|
|
112
113
|
})
|
|
@@ -163,7 +163,7 @@ describe('gateway boot-sweep grace period', () => {
|
|
|
163
163
|
|
|
164
164
|
describe('re-armed pending card re-holds the inbound gate (#2840)', () => {
|
|
165
165
|
it('turnInFlightForGate counts pending approvals', () => {
|
|
166
|
-
const gate = slice(GATEWAY, 'function turnInFlightForGate()',
|
|
166
|
+
const gate = slice(GATEWAY, 'function turnInFlightForGate()', 2400)
|
|
167
167
|
expect(gate).toMatch(/pendingPermissions\.size > 0/)
|
|
168
168
|
})
|
|
169
169
|
it('re-arm restores a pendingPermissions entry → gate held automatically', () => {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2975 Stage 2 — gateway-side read-only pre-approval predicate.
|
|
3
|
+
*
|
|
4
|
+
* `isDiffPreApproved` is what the gateway answers a hostd `check_pre_approved`
|
|
5
|
+
* query with. It MUST:
|
|
6
|
+
* - return true ONLY for a byte-exact registered (agent, diff) pair
|
|
7
|
+
* (mental-model proposal OR "🔁 Always allow" correlation);
|
|
8
|
+
* - be forge-resistant: a diff differing by ONE byte from the registered one
|
|
9
|
+
* is NOT pre-approved;
|
|
10
|
+
* - NEVER mutate the correlation store (the single-use auto-resolve delete
|
|
11
|
+
* stays on the real request_config_approval — a peek can't consume consent).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, vi } from "vitest";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
isDiffPreApproved,
|
|
18
|
+
type PreApprovalCheckDeps,
|
|
19
|
+
} from "../gateway/pre-approval-check.js";
|
|
20
|
+
|
|
21
|
+
const AGENT = "klanker";
|
|
22
|
+
const MM_DIFF =
|
|
23
|
+
"--- a/switchroom.yaml\n+++ b/switchroom.yaml\n@@ -1 +1,2 @@\n+ mental_models: [x]\n";
|
|
24
|
+
const ALLOW_RULE = "Bash";
|
|
25
|
+
const ALLOW_DIFF =
|
|
26
|
+
"--- a/switchroom.yaml\n+++ b/switchroom.yaml\n@@ -1 +1,2 @@\n+ - Bash\n";
|
|
27
|
+
|
|
28
|
+
function mmKey(agent: string, diff: string): string {
|
|
29
|
+
return `${agent}::${createHash("sha256").update(diff).digest("hex")}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A Map-backed store exposing only sweep()+get(), plus spies to prove no
|
|
33
|
+
* mutation happened (the ReadonlyCorrelationStore type has no delete/set, so
|
|
34
|
+
* a mutating edit wouldn't even typecheck — these spies double-check sweep). */
|
|
35
|
+
function makeStore(entries: Record<string, { unifiedDiff: string }>) {
|
|
36
|
+
const map = new Map(Object.entries(entries));
|
|
37
|
+
const sweep = vi.fn((_now: number) => {
|
|
38
|
+
/* no expiry in these tests */
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
store: {
|
|
42
|
+
sweep,
|
|
43
|
+
get: (k: string) => map.get(k),
|
|
44
|
+
},
|
|
45
|
+
map,
|
|
46
|
+
sweep,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function makeDeps(overrides: {
|
|
51
|
+
alwaysAllow?: Record<string, { unifiedDiff: string }>;
|
|
52
|
+
mentalModel?: Record<string, { unifiedDiff: string }>;
|
|
53
|
+
}): {
|
|
54
|
+
deps: PreApprovalCheckDeps;
|
|
55
|
+
aaMap: Map<string, { unifiedDiff: string }>;
|
|
56
|
+
mmMap: Map<string, { unifiedDiff: string }>;
|
|
57
|
+
} {
|
|
58
|
+
const aa = makeStore(overrides.alwaysAllow ?? {});
|
|
59
|
+
const mm = makeStore(overrides.mentalModel ?? {});
|
|
60
|
+
const deps: PreApprovalCheckDeps = {
|
|
61
|
+
alwaysAllow: aa.store,
|
|
62
|
+
mentalModel: mm.store,
|
|
63
|
+
// Only recognise the ALLOW_RULE token when it appears as an added line.
|
|
64
|
+
extractAddedAllowRule: (diff) =>
|
|
65
|
+
diff.includes(`+ - ${ALLOW_RULE}`) ? ALLOW_RULE : null,
|
|
66
|
+
mentalModelCorrelationKey: mmKey,
|
|
67
|
+
};
|
|
68
|
+
return { deps, aaMap: aa.map, mmMap: mm.map };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("isDiffPreApproved — mental-model correlation", () => {
|
|
72
|
+
it("true for a byte-exact registered (agent, diff) pair", () => {
|
|
73
|
+
const { deps } = makeDeps({
|
|
74
|
+
mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
|
|
75
|
+
});
|
|
76
|
+
expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("false (forge-resistant) for a diff differing by one byte", () => {
|
|
80
|
+
const { deps } = makeDeps({
|
|
81
|
+
mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
|
|
82
|
+
});
|
|
83
|
+
// The key is derived from the tampered diff → no entry at that key; and even
|
|
84
|
+
// if an attacker could guess the key, the stored unifiedDiff wouldn't match.
|
|
85
|
+
expect(isDiffPreApproved(AGENT, MM_DIFF + " ", deps)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("false for a matching diff under a DIFFERENT agent name", () => {
|
|
89
|
+
const { deps } = makeDeps({
|
|
90
|
+
mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
|
|
91
|
+
});
|
|
92
|
+
expect(isDiffPreApproved("someone-else", MM_DIFF, deps)).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("false when nothing is registered", () => {
|
|
96
|
+
const { deps } = makeDeps({});
|
|
97
|
+
expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("isDiffPreApproved — always-allow correlation", () => {
|
|
102
|
+
it("true for a byte-exact registered always-allow diff", () => {
|
|
103
|
+
const { deps } = makeDeps({
|
|
104
|
+
alwaysAllow: {
|
|
105
|
+
[`${AGENT}::${ALLOW_RULE}`]: { unifiedDiff: ALLOW_DIFF },
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
expect(isDiffPreApproved(AGENT, ALLOW_DIFF, deps)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("false when the added rule token matches but the diff bytes differ (forge)", () => {
|
|
112
|
+
const { deps } = makeDeps({
|
|
113
|
+
alwaysAllow: {
|
|
114
|
+
[`${AGENT}::${ALLOW_RULE}`]: { unifiedDiff: ALLOW_DIFF },
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
// Same rule token, but the diff smuggles an extra byte → byte-match fails.
|
|
118
|
+
expect(isDiffPreApproved(AGENT, ALLOW_DIFF + "\n", deps)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("isDiffPreApproved — read-only invariant", () => {
|
|
123
|
+
it("never mutates either correlation store on a match", () => {
|
|
124
|
+
const { deps, mmMap } = makeDeps({
|
|
125
|
+
mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
|
|
126
|
+
});
|
|
127
|
+
const sizeBefore = mmMap.size;
|
|
128
|
+
expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(true);
|
|
129
|
+
// The matched correlation is STILL present — a peek cannot consume the
|
|
130
|
+
// single-use consent the real request_config_approval will spend later.
|
|
131
|
+
expect(mmMap.size).toBe(sizeBefore);
|
|
132
|
+
expect(mmMap.has(mmKey(AGENT, MM_DIFF))).toBe(true);
|
|
133
|
+
// A second identical query still answers true (idempotent, no consumption).
|
|
134
|
+
expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("never mutates the store on a miss", () => {
|
|
138
|
+
const { deps, aaMap, mmMap } = makeDeps({
|
|
139
|
+
alwaysAllow: {
|
|
140
|
+
[`${AGENT}::${ALLOW_RULE}`]: { unifiedDiff: ALLOW_DIFF },
|
|
141
|
+
},
|
|
142
|
+
mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
|
|
143
|
+
});
|
|
144
|
+
expect(isDiffPreApproved(AGENT, "totally-unrelated-diff", deps)).toBe(false);
|
|
145
|
+
expect(aaMap.size).toBe(1);
|
|
146
|
+
expect(mmMap.size).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queued-card-store.test.ts — outcome-based tests for the queued-status /
|
|
3
|
+
* busy-ack card restart-survivability fix (#3002).
|
|
4
|
+
*
|
|
5
|
+
* PRE-FIX PROOF: before this change, no persistence existed for the
|
|
6
|
+
* `queuedStatusMsgIds` Map at all — `../gateway/queued-card-store.js` did not
|
|
7
|
+
* exist, so `import` itself failed at collection time and every test below
|
|
8
|
+
* failed on today's (pre-fix) base — there was no seam to test.
|
|
9
|
+
*
|
|
10
|
+
* This file exercises the REAL exported store + boot-reaper functions (not a
|
|
11
|
+
* structural grep), mirroring `activity-card-store.test.ts`'s in-memory fs seam
|
|
12
|
+
* convention, proving the exact outcome contract:
|
|
13
|
+
* (a) card post persists a record;
|
|
14
|
+
* (b) in-process reap clears it;
|
|
15
|
+
* (c) a boot reaper against a leftover record performs EXACTLY ONE Telegram
|
|
16
|
+
* delete and empties the store;
|
|
17
|
+
* (d) a second boot performs ZERO deletes;
|
|
18
|
+
* (e) a corrupt state file never crashes boot and is dropped without a delete;
|
|
19
|
+
* (f) a delete failure never crashes the reaper and still clears the row.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect } from 'vitest'
|
|
22
|
+
import {
|
|
23
|
+
loadQueuedCards,
|
|
24
|
+
persistQueuedCards,
|
|
25
|
+
writeQueuedCardRecord,
|
|
26
|
+
clearQueuedCardRecord,
|
|
27
|
+
runQueuedCardBootReaper,
|
|
28
|
+
type QueuedCardRecord,
|
|
29
|
+
type QueuedCardStoreFsSeam,
|
|
30
|
+
} from '../gateway/queued-card-store.js'
|
|
31
|
+
|
|
32
|
+
/** In-memory fs seam with an atomic rename — same convention as
|
|
33
|
+
* activity-card-store.test.ts's memFs, so the tmp→rename crash-safety
|
|
34
|
+
* contract is exercised without touching real disk. */
|
|
35
|
+
function memFs(seed: Record<string, string> = {}) {
|
|
36
|
+
const files = new Map<string, string>(Object.entries(seed))
|
|
37
|
+
const calls: string[] = []
|
|
38
|
+
const fs: QueuedCardStoreFsSeam = {
|
|
39
|
+
readFileSync: (p) => {
|
|
40
|
+
if (!files.has(p)) throw new Error(`ENOENT ${p}`)
|
|
41
|
+
return files.get(p)!
|
|
42
|
+
},
|
|
43
|
+
writeFileSync: (p, d) => {
|
|
44
|
+
calls.push(`write:${p}`)
|
|
45
|
+
files.set(p, d)
|
|
46
|
+
},
|
|
47
|
+
renameSync: (a, b) => {
|
|
48
|
+
calls.push(`rename:${a}->${b}`)
|
|
49
|
+
if (!files.has(a)) throw new Error(`ENOENT ${a}`)
|
|
50
|
+
files.set(b, files.get(a)!)
|
|
51
|
+
files.delete(a)
|
|
52
|
+
},
|
|
53
|
+
existsSync: (p) => files.has(p),
|
|
54
|
+
}
|
|
55
|
+
return { fs, files, calls }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PATH = '/state/agent/telegram/queued-cards-pending.json'
|
|
59
|
+
|
|
60
|
+
function card(over: Partial<QueuedCardRecord> = {}): QueuedCardRecord {
|
|
61
|
+
return {
|
|
62
|
+
key: '-100123:42',
|
|
63
|
+
chatId: '-100123',
|
|
64
|
+
threadId: 42,
|
|
65
|
+
messageId: 715,
|
|
66
|
+
...over,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('queued-card-store — persistence primitives', () => {
|
|
71
|
+
it('loads [] when no file exists (fresh boot)', () => {
|
|
72
|
+
const { fs } = memFs()
|
|
73
|
+
expect(loadQueuedCards(PATH, fs)).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('(a) card post → state persisted (round-trips a written record)', () => {
|
|
77
|
+
const { fs } = memFs()
|
|
78
|
+
const record = card()
|
|
79
|
+
writeQueuedCardRecord(PATH, fs, record)
|
|
80
|
+
const loaded = loadQueuedCards(PATH, fs)
|
|
81
|
+
expect(loaded).toHaveLength(1)
|
|
82
|
+
expect(loaded[0]).toEqual(record)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('round-trips a DM card (threadId null)', () => {
|
|
86
|
+
const { fs } = memFs()
|
|
87
|
+
const record = card({ key: '-100123:', threadId: null })
|
|
88
|
+
writeQueuedCardRecord(PATH, fs, record)
|
|
89
|
+
expect(loadQueuedCards(PATH, fs)[0]).toEqual(record)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('a fresh post for the same topic replaces (upserts) the stale row, not appends', () => {
|
|
93
|
+
const { fs } = memFs()
|
|
94
|
+
writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
|
|
95
|
+
writeQueuedCardRecord(PATH, fs, card({ messageId: 900 }))
|
|
96
|
+
const loaded = loadQueuedCards(PATH, fs)
|
|
97
|
+
expect(loaded).toHaveLength(1)
|
|
98
|
+
expect(loaded[0]!.messageId).toBe(900)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('keeps rows for distinct keys (different topics) side by side', () => {
|
|
102
|
+
const { fs } = memFs()
|
|
103
|
+
writeQueuedCardRecord(PATH, fs, card({ key: '-100123:42', messageId: 715 }))
|
|
104
|
+
writeQueuedCardRecord(PATH, fs, card({ key: '-100123:43', messageId: 716 }))
|
|
105
|
+
expect(loadQueuedCards(PATH, fs)).toHaveLength(2)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('(b) in-process reap → state cleared', () => {
|
|
109
|
+
const { fs } = memFs()
|
|
110
|
+
writeQueuedCardRecord(PATH, fs, card())
|
|
111
|
+
expect(loadQueuedCards(PATH, fs)).toHaveLength(1)
|
|
112
|
+
clearQueuedCardRecord(PATH, fs, '-100123:42')
|
|
113
|
+
expect(loadQueuedCards(PATH, fs)).toEqual([])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('clearing an absent/mismatched key is a no-op (never throws)', () => {
|
|
117
|
+
const { fs } = memFs()
|
|
118
|
+
writeQueuedCardRecord(PATH, fs, card({ key: '-100123:99' }))
|
|
119
|
+
clearQueuedCardRecord(PATH, fs, '-100123:42')
|
|
120
|
+
expect(loadQueuedCards(PATH, fs)).toHaveLength(1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('reap-race guard: clear scoped to messageId leaves a fresh live card intact', () => {
|
|
124
|
+
const { fs } = memFs()
|
|
125
|
+
// A live turn posted a NEW card (msg 900) under the same key while a stale
|
|
126
|
+
// reap tries to clear the OLD one (msg 715) — the new row must survive.
|
|
127
|
+
writeQueuedCardRecord(PATH, fs, card({ messageId: 900 }))
|
|
128
|
+
clearQueuedCardRecord(PATH, fs, '-100123:42', 715)
|
|
129
|
+
const loaded = loadQueuedCards(PATH, fs)
|
|
130
|
+
expect(loaded).toHaveLength(1)
|
|
131
|
+
expect(loaded[0]!.messageId).toBe(900)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('writes via tmp + atomic rename (crash-safe)', () => {
|
|
135
|
+
const { fs, calls } = memFs()
|
|
136
|
+
persistQueuedCards(PATH, fs, [card()])
|
|
137
|
+
expect(calls).toEqual([`write:${PATH}.tmp`, `rename:${PATH}.tmp->${PATH}`])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('(e) corrupt state file: boot survives, loads as [] (fail-open, never throws)', () => {
|
|
141
|
+
const { fs } = memFs({ [PATH]: '{not json' })
|
|
142
|
+
expect(() => loadQueuedCards(PATH, fs)).not.toThrow()
|
|
143
|
+
expect(loadQueuedCards(PATH, fs)).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('fail-open: wrong envelope version / malformed shape loads as []', () => {
|
|
147
|
+
const { fs: f1 } = memFs({ [PATH]: JSON.stringify({ v: 2, cards: [card()] }) })
|
|
148
|
+
expect(loadQueuedCards(PATH, f1)).toEqual([])
|
|
149
|
+
const { fs: f2 } = memFs({ [PATH]: JSON.stringify({ v: 1, cards: 'nope' }) })
|
|
150
|
+
expect(loadQueuedCards(PATH, f2)).toEqual([])
|
|
151
|
+
const { fs: f3 } = memFs({ [PATH]: JSON.stringify({ v: 1, cards: [{ key: '' }] }) })
|
|
152
|
+
expect(loadQueuedCards(PATH, f3)).toEqual([])
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('queued-card-store — boot reaper (reap-on-boot, delete is the terminal)', () => {
|
|
157
|
+
it('(c) deletes each persisted card EXACTLY ONCE and empties the store', async () => {
|
|
158
|
+
const { fs } = memFs()
|
|
159
|
+
writeQueuedCardRecord(PATH, fs, card({ key: '-100123:42', messageId: 715 }))
|
|
160
|
+
writeQueuedCardRecord(PATH, fs, card({ key: '-100123:43', messageId: 716 }))
|
|
161
|
+
const deletes: number[] = []
|
|
162
|
+
const res = await runQueuedCardBootReaper({
|
|
163
|
+
path: PATH,
|
|
164
|
+
fs,
|
|
165
|
+
deleteCard: async (r) => {
|
|
166
|
+
deletes.push(r.messageId)
|
|
167
|
+
return { message_id: r.messageId }
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
expect(res).toEqual({ deleted: 2, total: 2 })
|
|
171
|
+
expect(deletes.sort()).toEqual([715, 716])
|
|
172
|
+
// Store is emptied — the stale cards can never be re-reaped.
|
|
173
|
+
expect(loadQueuedCards(PATH, fs)).toEqual([])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('(d) a SECOND boot performs ZERO deletes (store already drained)', async () => {
|
|
177
|
+
const { fs } = memFs()
|
|
178
|
+
writeQueuedCardRecord(PATH, fs, card())
|
|
179
|
+
await runQueuedCardBootReaper({ path: PATH, fs, deleteCard: async () => ({}) })
|
|
180
|
+
const deletes: number[] = []
|
|
181
|
+
const res = await runQueuedCardBootReaper({
|
|
182
|
+
path: PATH,
|
|
183
|
+
fs,
|
|
184
|
+
deleteCard: async (r) => {
|
|
185
|
+
deletes.push(r.messageId)
|
|
186
|
+
return {}
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
expect(res).toEqual({ deleted: 0, total: 0 })
|
|
190
|
+
expect(deletes).toEqual([])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('deletes the record BEFORE the delete call (idempotency guard — no double-delete on crash)', async () => {
|
|
194
|
+
const { fs } = memFs()
|
|
195
|
+
writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
|
|
196
|
+
let storeAtDeleteTime: QueuedCardRecord[] | null = null
|
|
197
|
+
await runQueuedCardBootReaper({
|
|
198
|
+
path: PATH,
|
|
199
|
+
fs,
|
|
200
|
+
deleteCard: async () => {
|
|
201
|
+
// At the moment the Telegram delete runs, the row is already gone from
|
|
202
|
+
// disk — a crash here or a concurrent second boot cannot re-issue it.
|
|
203
|
+
storeAtDeleteTime = loadQueuedCards(PATH, fs)
|
|
204
|
+
return {}
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
expect(storeAtDeleteTime).toEqual([])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('(f) a delete FAILURE never crashes the reaper and still clears the row (best-effort, tolerate "not found")', async () => {
|
|
211
|
+
const { fs } = memFs()
|
|
212
|
+
writeQueuedCardRecord(PATH, fs, card({ messageId: 715 }))
|
|
213
|
+
const res = await runQueuedCardBootReaper({
|
|
214
|
+
path: PATH,
|
|
215
|
+
fs,
|
|
216
|
+
deleteCard: async () => {
|
|
217
|
+
throw new Error('Bad Request: message to delete not found')
|
|
218
|
+
},
|
|
219
|
+
log: () => {},
|
|
220
|
+
})
|
|
221
|
+
// Not counted as deleted, but the row is dropped (delete-first) so it can't
|
|
222
|
+
// wedge every future boot.
|
|
223
|
+
expect(res).toEqual({ deleted: 0, total: 1 })
|
|
224
|
+
expect(loadQueuedCards(PATH, fs)).toEqual([])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('empty store: reaper is a no-op', async () => {
|
|
228
|
+
const { fs } = memFs()
|
|
229
|
+
const res = await runQueuedCardBootReaper({ path: PATH, fs, deleteCard: async () => ({}) })
|
|
230
|
+
expect(res).toEqual({ deleted: 0, total: 0 })
|
|
231
|
+
})
|
|
232
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2094 finding 3 — reaction-flush uses the #1556 turn gate.
|
|
3
|
+
*
|
|
4
|
+
* THE BUG: flushReactionBatch delivered its synthetic reaction inbound with a
|
|
5
|
+
* bare `ipcServer.sendToAgent` + busy-mark, buffering ONLY on a bridge-offline
|
|
6
|
+
* miss. It never ran the #1556 decideInboundDelivery gate. So a reaction that
|
|
7
|
+
* landed WHILE a turn was in flight fired the MCP channel notification mid-turn
|
|
8
|
+
* — the unmodified CLI typed it into its TUI composer and the auto-submit raced
|
|
9
|
+
* turn-completion, re-creating the composer wedge.
|
|
10
|
+
*
|
|
11
|
+
* THE FIX: route the flush through deliverResumeSyntheticOrBuffer, the shared
|
|
12
|
+
* helper that consults the gate (mid-turn → buffer-until-idle, flushed by the
|
|
13
|
+
* turn-complete hook + idle-drain timer; idle → deliver now; buffer only on a
|
|
14
|
+
* genuine miss).
|
|
15
|
+
*
|
|
16
|
+
* gateway.ts is a large side-effecting module that can't be imported into a
|
|
17
|
+
* unit test, so this file pins (a) the gate decision for a reaction inbound's
|
|
18
|
+
* shape via the pure gate + a modeled buffer/drain, and (b) that
|
|
19
|
+
* flushReactionBatch wires the turn-safe helper structurally — the same
|
|
20
|
+
* strategy button-tap-turn-gated.test.ts uses.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect } from 'vitest'
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { resolve } from 'node:path'
|
|
25
|
+
import { decideInboundDelivery } from '../gateway/inbound-delivery-gate.js'
|
|
26
|
+
import { planBufferedRedelivery } from '../gateway/pending-inbound-buffer.js'
|
|
27
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
28
|
+
|
|
29
|
+
const gatewaySrc = readFileSync(
|
|
30
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
31
|
+
'utf-8',
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
// A reaction inbound as flushReactionBatch builds it: synthetic messageId (ts),
|
|
35
|
+
// never steering, never an interrupt — the exact inputs the shared helper
|
|
36
|
+
// (deliverResumeSyntheticOrBuffer) passes to the gate.
|
|
37
|
+
function reactionInbound(ts: number): InboundMessage {
|
|
38
|
+
return {
|
|
39
|
+
type: 'inbound',
|
|
40
|
+
chatId: 'c1',
|
|
41
|
+
messageId: ts,
|
|
42
|
+
user: 'u',
|
|
43
|
+
userId: 42,
|
|
44
|
+
ts,
|
|
45
|
+
text: `[user reacted 👍 to your message]`,
|
|
46
|
+
meta: { chat_id: 'c1', count: '1' },
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('reaction flush uses the turn-gate (mid-turn → buffer)', () => {
|
|
51
|
+
it('buffers a reaction that lands mid-turn, delivers when idle, never mid-turn', () => {
|
|
52
|
+
const shape = { isSteering: false as const, isInterrupt: false as const }
|
|
53
|
+
// Reaction lands WHILE a turn is in flight → HELD, not sent (the wedge the
|
|
54
|
+
// raw sendToAgent used to cause).
|
|
55
|
+
expect(decideInboundDelivery({ ...shape, turnInFlight: true })).toBe(
|
|
56
|
+
'buffer-until-idle',
|
|
57
|
+
)
|
|
58
|
+
// Reaction lands at an idle prompt → delivered immediately.
|
|
59
|
+
expect(decideInboundDelivery({ ...shape, turnInFlight: false })).toBe(
|
|
60
|
+
'deliver',
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('a mid-turn-buffered reaction drains as a fresh turn after turn end', () => {
|
|
65
|
+
// Model the buffer→drain: the reaction was held mid-turn; on turn-complete
|
|
66
|
+
// the buffer is planned for redelivery and the reaction lands cleanly as
|
|
67
|
+
// its own turn (never merged away / dropped).
|
|
68
|
+
const plan = planBufferedRedelivery([reactionInbound(1000)])
|
|
69
|
+
expect(plan).toHaveLength(1)
|
|
70
|
+
expect(plan[0]!.merged.text).toBe('[user reacted 👍 to your message]')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('flushReactionBatch wires the turn-safe helper', () => {
|
|
75
|
+
const start = gatewaySrc.indexOf('function flushReactionBatch(batch: ReactionBatch)')
|
|
76
|
+
// Bound the body by the next top-level declaration (the message_reaction
|
|
77
|
+
// handler block).
|
|
78
|
+
const body = gatewaySrc.slice(
|
|
79
|
+
start,
|
|
80
|
+
gatewaySrc.indexOf("bot.on('message_reaction'", start),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
it('exists', () => {
|
|
84
|
+
expect(start, 'flushReactionBatch missing').toBeGreaterThan(0)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('delivers via deliverResumeSyntheticOrBuffer, not a raw ungated sendToAgent', () => {
|
|
88
|
+
expect(body).toMatch(/deliverResumeSyntheticOrBuffer\(agentName, inbound\)/)
|
|
89
|
+
// The regressed path — a raw ungated send of the reaction inbound + an
|
|
90
|
+
// out-of-helper busy-mark — is gone.
|
|
91
|
+
expect(body).not.toMatch(/ipcServer\.sendToAgent\(agentName, inbound\)/)
|
|
92
|
+
expect(body).not.toMatch(/markClaudeBusyForInbound\(inbound\)/)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('no longer open-codes the buffer-on-failure push (the helper owns it)', () => {
|
|
96
|
+
// The #1150 buffer-on-failure guarantee now lives inside
|
|
97
|
+
// deliverResumeSyntheticOrBuffer, so the flush body must not push directly.
|
|
98
|
+
expect(body).not.toMatch(/pendingInboundBuffer\.push\(/)
|
|
99
|
+
})
|
|
100
|
+
})
|