switchroom 0.18.11 → 0.18.13
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 +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- 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 +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -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 +88 -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 +1695 -230
- 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/history.ts +51 -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 +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -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/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -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 +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- 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 +173 -18
- 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 +327 -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/callback-query-handlers.test.ts +65 -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/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -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/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -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/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -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/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -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/subagent-watcher-fd-leak.test.ts +275 -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/tests/worktree-watch-cwds.test.ts +215 -1
- 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/telegram-plugin/worktree-watch-cwds.ts +194 -5
- 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
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -138,6 +138,129 @@ describe("resolveMentalModelProposal — APPROVE", () => {
|
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
describe("resolveMentalModelProposal — #2975 Stage 1 rate-limit backstop", () => {
|
|
142
|
+
const NOW = 1_700_000_000_000;
|
|
143
|
+
const RETRY_AT = NOW + 55 * 60_000; // hostd's window-open time
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build deps whose dispatchConfigEdit yields `results` in order (one per
|
|
147
|
+
* attempt), plus a manual single-slot scheduler the test fires by hand — a
|
|
148
|
+
* mocked clock, no real timers.
|
|
149
|
+
*/
|
|
150
|
+
function rateDeps(results: Array<Awaited<ReturnType<ResolveDeps["dispatchConfigEdit"]>>>) {
|
|
151
|
+
let call = 0;
|
|
152
|
+
let scheduled: { delayMs: number; fn: () => void | Promise<void> } | null = null;
|
|
153
|
+
const spies = {
|
|
154
|
+
registerPreApproval: vi.fn(),
|
|
155
|
+
clearPreApproval: vi.fn(),
|
|
156
|
+
dispatchConfigEdit: vi.fn(async () => results[Math.min(call++, results.length - 1)]!),
|
|
157
|
+
ensureModel: vi.fn(async () => {}),
|
|
158
|
+
injectInbound: vi.fn(),
|
|
159
|
+
scheduleRetry: vi.fn((delayMs: number, fn: () => void | Promise<void>) => {
|
|
160
|
+
scheduled = { delayMs, fn };
|
|
161
|
+
}),
|
|
162
|
+
editProposalCardRateWindow: vi.fn(),
|
|
163
|
+
notifyPersistFailed: vi.fn(),
|
|
164
|
+
};
|
|
165
|
+
const deps: ResolveDeps = {
|
|
166
|
+
readConfigText: () => CONFIG_NO_MODELS,
|
|
167
|
+
registerPreApproval: spies.registerPreApproval,
|
|
168
|
+
clearPreApproval: spies.clearPreApproval,
|
|
169
|
+
dispatchConfigEdit: spies.dispatchConfigEdit,
|
|
170
|
+
ensureModel: spies.ensureModel,
|
|
171
|
+
injectInbound: spies.injectInbound,
|
|
172
|
+
scheduleRetry: spies.scheduleRetry,
|
|
173
|
+
editProposalCardRateWindow: spies.editProposalCardRateWindow,
|
|
174
|
+
notifyPersistFailed: spies.notifyPersistFailed,
|
|
175
|
+
now: () => NOW,
|
|
176
|
+
};
|
|
177
|
+
return { deps, spies, fireScheduled: () => scheduled!.fn(), getScheduled: () => scheduled };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
it("schedules EXACTLY ONE retry at retry_after, which persists with a byte-identical diff", async () => {
|
|
181
|
+
const { deps, spies, fireScheduled, getScheduled } = rateDeps([
|
|
182
|
+
{ state: "rate_limited", reason: "config_propose_edit rate limit exceeded", retryAtMs: RETRY_AT },
|
|
183
|
+
{ state: "applied" },
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
const out = await resolveMentalModelProposal("approve", pending(), "stage1", "op-42", deps);
|
|
187
|
+
|
|
188
|
+
// The turn is deferred, not lost: a single retry is scheduled at the window.
|
|
189
|
+
expect(out).toEqual({ outcome: "scheduled_retry", retryAtMs: RETRY_AT });
|
|
190
|
+
expect(spies.scheduleRetry).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(getScheduled()!.delayMs).toBe(RETRY_AT - NOW);
|
|
192
|
+
// Card flipped to the "applying at HH:MM" state so the operator sees it.
|
|
193
|
+
expect(spies.editProposalCardRateWindow).toHaveBeenCalledWith(RETRY_AT);
|
|
194
|
+
// First (rate-limited) attempt already registered+cleared its correlation.
|
|
195
|
+
expect(spies.dispatchConfigEdit).toHaveBeenCalledTimes(1);
|
|
196
|
+
const firstDiff = spies.dispatchConfigEdit.mock.calls[0][0].diff;
|
|
197
|
+
expect(spies.registerPreApproval).toHaveBeenCalledWith("coach", firstDiff);
|
|
198
|
+
// Nothing applied yet.
|
|
199
|
+
expect(spies.ensureModel).not.toHaveBeenCalled();
|
|
200
|
+
expect(spies.injectInbound).not.toHaveBeenCalled();
|
|
201
|
+
|
|
202
|
+
// Fire the ONE scheduled retry (mocked clock).
|
|
203
|
+
await fireScheduled();
|
|
204
|
+
|
|
205
|
+
// Persisted on the retry.
|
|
206
|
+
expect(spies.dispatchConfigEdit).toHaveBeenCalledTimes(2);
|
|
207
|
+
const retryDiff = spies.dispatchConfigEdit.mock.calls[1][0].diff;
|
|
208
|
+
// Forge-resistance contract: register + dispatch use IDENTICAL diff bytes,
|
|
209
|
+
// and the retry re-registers the SAME bytes as the first attempt.
|
|
210
|
+
expect(retryDiff).toBe(firstDiff);
|
|
211
|
+
expect(spies.registerPreApproval).toHaveBeenCalledTimes(2);
|
|
212
|
+
expect(spies.registerPreApproval.mock.calls[1]).toEqual(["coach", retryDiff]);
|
|
213
|
+
expect(spies.clearPreApproval).toHaveBeenCalledTimes(2);
|
|
214
|
+
// Applied: ensured + agent woken; NO loud failure.
|
|
215
|
+
expect(spies.ensureModel).toHaveBeenCalledWith(pending().spec);
|
|
216
|
+
expect(spies.injectInbound).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
218
|
+
"mental_model_proposal_applied",
|
|
219
|
+
);
|
|
220
|
+
expect(spies.notifyPersistFailed).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("emits a loud operator failure when the single retry ALSO fails — and does NOT re-schedule", async () => {
|
|
224
|
+
const { deps, spies, fireScheduled } = rateDeps([
|
|
225
|
+
{ state: "rate_limited", reason: "rate limit exceeded", retryAtMs: RETRY_AT },
|
|
226
|
+
{ state: "error", reason: "hostd down" },
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const out = await resolveMentalModelProposal("approve", pending(), "s", "op", deps);
|
|
230
|
+
expect(out.outcome).toBe("scheduled_retry");
|
|
231
|
+
expect(spies.scheduleRetry).toHaveBeenCalledTimes(1);
|
|
232
|
+
|
|
233
|
+
await fireScheduled();
|
|
234
|
+
|
|
235
|
+
// The retry failed: loud operator-events notification, exactly once.
|
|
236
|
+
expect(spies.notifyPersistFailed).toHaveBeenCalledTimes(1);
|
|
237
|
+
expect(spies.notifyPersistFailed.mock.calls[0][0]).toContain("hostd down");
|
|
238
|
+
// No second retry — bounded to exactly one.
|
|
239
|
+
expect(spies.scheduleRetry).toHaveBeenCalledTimes(1);
|
|
240
|
+
// Agent woken with the failed inbound; nothing ensured.
|
|
241
|
+
expect(spies.ensureModel).not.toHaveBeenCalled();
|
|
242
|
+
expect(spies.injectInbound).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
244
|
+
"mental_model_proposal_failed",
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("with no scheduler wired, a rate-limited persist fails loudly instead of silently dropping", async () => {
|
|
249
|
+
const { deps, spies } = rateDeps([
|
|
250
|
+
{ state: "rate_limited", reason: "rate limit exceeded", retryAtMs: RETRY_AT },
|
|
251
|
+
]);
|
|
252
|
+
// Simulate a build without the Stage-1 scheduler dep.
|
|
253
|
+
delete (deps as { scheduleRetry?: unknown }).scheduleRetry;
|
|
254
|
+
|
|
255
|
+
const out = await resolveMentalModelProposal("approve", pending(), "s", "op", deps);
|
|
256
|
+
expect(out).toEqual({ outcome: "failed", reason: "rate limit exceeded" });
|
|
257
|
+
expect(spies.notifyPersistFailed).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
259
|
+
"mental_model_proposal_failed",
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
141
264
|
describe("resolveMentalModelProposal — DENY writes NOTHING", () => {
|
|
142
265
|
it("never reads config, builds a diff, dispatches an edit, or ensures", async () => {
|
|
143
266
|
const { deps, spies } = makeDeps(CONFIG_NO_MODELS);
|
|
@@ -37,7 +37,7 @@ describe('missed-approvals re-offer wiring (#2862)', () => {
|
|
|
37
37
|
|
|
38
38
|
it('appends the miss at TTL auto-deny, anchored to the card origin, gated by the kill switch', () => {
|
|
39
39
|
// Within the pending-permission TTL sweep block.
|
|
40
|
-
const sweep = slice(GATEWAY, '
|
|
40
|
+
const sweep = slice(GATEWAY, 'sweepPermissionTtl({', 5400)
|
|
41
41
|
expect(sweep).toContain('if (MISSED_APPROVAL_REOFFER_ENABLED) {')
|
|
42
42
|
expect(sweep).toContain('missedApprovalsStore.add({')
|
|
43
43
|
expect(sweep).toContain('const origin = v.cards[0]')
|
|
@@ -1359,4 +1359,18 @@ describe("isOfflineTrustedModelToken (#3042 blocker 2a)", () => {
|
|
|
1359
1359
|
expect(isOfflineTrustedModelToken("sr-made-up/model")).toBe(false);
|
|
1360
1360
|
expect(isOfflineTrustedModelToken("")).toBe(false);
|
|
1361
1361
|
});
|
|
1362
|
+
|
|
1363
|
+
it("#3043 item 1: accepts case-variant aliases the live path already normalizes (OPUS, Sonnet)", () => {
|
|
1364
|
+
// The live accept path lowercases (isClaudeModel / expandSrAlias) so a
|
|
1365
|
+
// queued `/model OPUS` is accepted live — the persist gate must not then
|
|
1366
|
+
// refuse it with the over-conservative "couldn't verify" card.
|
|
1367
|
+
for (const a of MODEL_ALIASES) {
|
|
1368
|
+
expect(isOfflineTrustedModelToken(a.toUpperCase())).toBe(true);
|
|
1369
|
+
}
|
|
1370
|
+
expect(isOfflineTrustedModelToken("OPUS")).toBe(true);
|
|
1371
|
+
expect(isOfflineTrustedModelToken("Sonnet")).toBe(true);
|
|
1372
|
+
// Curated sr-* alias, upper-cased, still resolves.
|
|
1373
|
+
const [alias] = Object.entries(SR_MODEL_ALIASES)[0];
|
|
1374
|
+
expect(isOfflineTrustedModelToken(alias.toUpperCase())).toBe(true);
|
|
1375
|
+
});
|
|
1362
1376
|
});
|
|
@@ -9,6 +9,7 @@ import { tmpdir } from 'os'
|
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { detectErrorInTranscriptLine, startSessionTail } from '../session-tail.js'
|
|
11
11
|
import { resetAllCooldowns } from '../operator-events.js'
|
|
12
|
+
import { resolveModelUnavailableFromOperatorEvent } from '../model-unavailable.js'
|
|
12
13
|
|
|
13
14
|
// ─── detectErrorInTranscriptLine unit tests ───────────────────────────────────
|
|
14
15
|
|
|
@@ -155,6 +156,79 @@ describe('detectErrorInTranscriptLine — error detection', () => {
|
|
|
155
156
|
expect(result!.detail).toContain('hit your limit')
|
|
156
157
|
})
|
|
157
158
|
|
|
159
|
+
// Regression — the carrie incident (2026-07-12). Anthropic also emits a
|
|
160
|
+
// 429 for a TRANSIENT per-account burst / RPM throttle whose wording
|
|
161
|
+
// explicitly negates the account-quota reading ("would exceed your
|
|
162
|
+
// account's rate limit … not your usage limit"). That is a self-healing
|
|
163
|
+
// few-second throttle Claude Code retries internally — it must NOT be
|
|
164
|
+
// labeled quota-exhausted (which always shows the scary "model
|
|
165
|
+
// unavailable" card + drives failover). It must take the calm
|
|
166
|
+
// rate-limited path.
|
|
167
|
+
it('classifies a TRANSIENT burst 429 (explicit negation) as rate-limited, NOT quota-exhausted', () => {
|
|
168
|
+
const line = JSON.stringify({
|
|
169
|
+
type: 'assistant',
|
|
170
|
+
message: {
|
|
171
|
+
role: 'assistant',
|
|
172
|
+
model: '<synthetic>',
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: 'text',
|
|
176
|
+
text:
|
|
177
|
+
'API Error: 429 rate_limit_error This request would exceed ' +
|
|
178
|
+
"your account's rate limit. Please try again later. This is a " +
|
|
179
|
+
'short-term burst limit, not your usage limit.',
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
error: 'rate_limit',
|
|
184
|
+
isApiErrorMessage: true,
|
|
185
|
+
apiErrorStatus: 429,
|
|
186
|
+
})
|
|
187
|
+
const result = detectErrorInTranscriptLine(line)
|
|
188
|
+
expect(result).not.toBeNull()
|
|
189
|
+
// Wording-based classification: explicit transient negation → calm path.
|
|
190
|
+
expect(result!.kind).toBe('rate-limited')
|
|
191
|
+
expect(result!.transient).toBe(true)
|
|
192
|
+
// End-to-end: the resolver must NOT produce a model-unavailable card for
|
|
193
|
+
// this kind (the calm rate-limited branch returns null on a bare burst).
|
|
194
|
+
const detection = resolveModelUnavailableFromOperatorEvent({
|
|
195
|
+
kind: result!.kind,
|
|
196
|
+
detail: result!.detail,
|
|
197
|
+
})
|
|
198
|
+
expect(detection).toBeNull()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
// Guard against over-correcting: a GENUINE quota wall (no transient marker)
|
|
202
|
+
// must STILL be quota-exhausted AND still resolve to a card.
|
|
203
|
+
it('a genuine quota-wall 429 still produces the quota-exhausted card', () => {
|
|
204
|
+
const line = JSON.stringify({
|
|
205
|
+
type: 'assistant',
|
|
206
|
+
message: {
|
|
207
|
+
role: 'assistant',
|
|
208
|
+
model: '<synthetic>',
|
|
209
|
+
content: [
|
|
210
|
+
{
|
|
211
|
+
type: 'text',
|
|
212
|
+
text: "You've hit your limit · resets 8:50am (Australia/Melbourne)",
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
error: 'rate_limit',
|
|
217
|
+
isApiErrorMessage: true,
|
|
218
|
+
apiErrorStatus: 429,
|
|
219
|
+
})
|
|
220
|
+
const result = detectErrorInTranscriptLine(line)
|
|
221
|
+
expect(result).not.toBeNull()
|
|
222
|
+
expect(result!.kind).toBe('quota-exhausted')
|
|
223
|
+
// The quota-exhausted branch ALWAYS returns a detection (the card).
|
|
224
|
+
const detection = resolveModelUnavailableFromOperatorEvent({
|
|
225
|
+
kind: result!.kind,
|
|
226
|
+
detail: result!.detail,
|
|
227
|
+
})
|
|
228
|
+
expect(detection).not.toBeNull()
|
|
229
|
+
expect(detection!.kind).toBe('quota_exhausted')
|
|
230
|
+
})
|
|
231
|
+
|
|
158
232
|
it('still returns null for a normal (non-error) assistant message', () => {
|
|
159
233
|
// No isApiErrorMessage flag → must NOT be treated as an error.
|
|
160
234
|
const line = JSON.stringify({
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
redactAskUserFields,
|
|
4
|
+
redactChecklistFields,
|
|
5
|
+
} from '../outbound-field-redact.js'
|
|
6
|
+
import { redact } from '../secret-detect/redact.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OUTCOME tests for the #2044 structured-payload outbound scrub (F1/F2).
|
|
10
|
+
*
|
|
11
|
+
* These exercise the exact helpers the gateway calls (redactAskUserFields
|
|
12
|
+
* for ask_user, redactChecklistFields for send_checklist / update_checklist),
|
|
13
|
+
* injecting the SAME real `redact()` the gateway's redactOutboundText wraps.
|
|
14
|
+
* The assertion is on the SENT PAYLOAD the helper returns — a secret echoed
|
|
15
|
+
* into a question, an option/button label, a checklist title, or a task's
|
|
16
|
+
* text must be masked (marker present, raw token absent) — not merely that a
|
|
17
|
+
* redactor was invoked.
|
|
18
|
+
*
|
|
19
|
+
* Secrets are assembled at runtime (per CLAUDE.md "Secrets in tests") so the
|
|
20
|
+
* source file never contains a contiguous token that trips push-protection.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// A realistic GitHub classic PAT shape (ghp_ + 36 chars) — high-confidence,
|
|
24
|
+
// masked by redact() to a [REDACTED:github_pat_classic] marker.
|
|
25
|
+
const SECRET = 'ghp' + '_' + 'AbCdEfGhIj0123456789KlMnOpQrStUvWxYz'
|
|
26
|
+
|
|
27
|
+
describe('redactAskUserFields — ask_user outbound scrub (F1)', () => {
|
|
28
|
+
it('masks a secret echoed into the QUESTION text', () => {
|
|
29
|
+
const out = redactAskUserFields(
|
|
30
|
+
`Deploy with token ${SECRET}?`,
|
|
31
|
+
['Yes', 'No'],
|
|
32
|
+
redact,
|
|
33
|
+
)
|
|
34
|
+
expect(out.question).not.toContain(SECRET)
|
|
35
|
+
expect(out.question).toContain('[REDACTED')
|
|
36
|
+
// Non-secret prose is preserved.
|
|
37
|
+
expect(out.question).toContain('Deploy with token')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('masks a secret echoed into an OPTION / button label', () => {
|
|
41
|
+
const out = redactAskUserFields(
|
|
42
|
+
'Which key?',
|
|
43
|
+
['Cancel', `Use ${SECRET}`],
|
|
44
|
+
redact,
|
|
45
|
+
)
|
|
46
|
+
expect(out.options[0]).toBe('Cancel')
|
|
47
|
+
expect(out.options[1]).not.toContain(SECRET)
|
|
48
|
+
expect(out.options[1]).toContain('[REDACTED')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('leaves clean question + options untouched', () => {
|
|
52
|
+
const out = redactAskUserFields('Proceed?', ['Yes', 'No'], redact)
|
|
53
|
+
expect(out.question).toBe('Proceed?')
|
|
54
|
+
expect(out.options).toEqual(['Yes', 'No'])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('does not mutate the caller-supplied options array', () => {
|
|
58
|
+
const options = ['Cancel', `Use ${SECRET}`]
|
|
59
|
+
redactAskUserFields('q', options, redact)
|
|
60
|
+
expect(options[1]).toBe(`Use ${SECRET}`) // original array untouched
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('redactChecklistFields — checklist outbound scrub (F2)', () => {
|
|
65
|
+
it('masks a secret in the checklist TITLE', () => {
|
|
66
|
+
const out = redactChecklistFields(
|
|
67
|
+
`Rotate ${SECRET}`,
|
|
68
|
+
[{ text: 'step one' }],
|
|
69
|
+
redact,
|
|
70
|
+
)
|
|
71
|
+
expect(out.title).not.toContain(SECRET)
|
|
72
|
+
expect(out.title).toContain('[REDACTED')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('masks a secret in a TASK text and preserves other task fields', () => {
|
|
76
|
+
const out = redactChecklistFields(
|
|
77
|
+
'Onboarding',
|
|
78
|
+
[
|
|
79
|
+
{ text: 'read the docs', done: true },
|
|
80
|
+
{ text: `save ${SECRET} to vault`, id: '7' },
|
|
81
|
+
],
|
|
82
|
+
redact,
|
|
83
|
+
)
|
|
84
|
+
expect(out.tasks![0]).toEqual({ text: 'read the docs', done: true })
|
|
85
|
+
expect(out.tasks![1]!.text).not.toContain(SECRET)
|
|
86
|
+
expect(out.tasks![1]!.text).toContain('[REDACTED')
|
|
87
|
+
// Sibling fields on the redacted task are carried through untouched.
|
|
88
|
+
expect(out.tasks![1]!.id).toBe('7')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('passes undefined title / tasks through (update_checklist partial patch)', () => {
|
|
92
|
+
const out = redactChecklistFields(undefined, undefined, redact)
|
|
93
|
+
expect(out.title).toBeUndefined()
|
|
94
|
+
expect(out.tasks).toBeUndefined()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('leaves an id-only task (no text) untouched', () => {
|
|
98
|
+
const out = redactChecklistFields(undefined, [{ id: '3', done: true }], redact)
|
|
99
|
+
expect(out.tasks![0]).toEqual({ id: '3', done: true })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('does not mutate the caller-supplied task objects', () => {
|
|
103
|
+
const tasks = [{ text: `save ${SECRET}` }]
|
|
104
|
+
redactChecklistFields('t', tasks, redact)
|
|
105
|
+
expect(tasks[0]!.text).toBe(`save ${SECRET}`) // original untouched
|
|
106
|
+
})
|
|
107
|
+
})
|
|
@@ -319,4 +319,25 @@ describe('drainTakenCommands (#3042 blocker 1: an early stop must not drop the r
|
|
|
319
319
|
await drainTakenCommands(batch(), io)
|
|
320
320
|
expect(events).toEqual(['edit:model:persisted:model', 'edit:effort:persisted:effort'])
|
|
321
321
|
})
|
|
322
|
+
|
|
323
|
+
it('#3021: restart-pending is snapshotted at entry → sync loop emptying pendingRestarts mid-drain still defers the SECOND command', async () => {
|
|
324
|
+
// Reproduces the turn-end idle-gate race: the drain is void-dispatched while
|
|
325
|
+
// a synchronous pendingRestarts loop empties the map right after. The first
|
|
326
|
+
// read sees the entry; by the second command the map has been emptied. A
|
|
327
|
+
// naive per-command re-check would let the second command apply live into a
|
|
328
|
+
// session ~100ms from triggerSelfRestart and falsely confirm.
|
|
329
|
+
let restartPresent = true
|
|
330
|
+
const { io, events } = makeIo({
|
|
331
|
+
// takeAll() first read latches true; the sync loop then empties the map,
|
|
332
|
+
// so every subsequent live read returns false.
|
|
333
|
+
restartPending: () => {
|
|
334
|
+
const was = restartPresent
|
|
335
|
+
restartPresent = false // the sync loop deleted the entry after dispatch
|
|
336
|
+
return was
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
await drainTakenCommands(batch(), io)
|
|
340
|
+
// BOTH commands must ride the carriers — the second must NOT apply live.
|
|
341
|
+
expect(events).toEqual(['edit:model:persisted:model', 'edit:effort:persisted:effort'])
|
|
342
|
+
})
|
|
322
343
|
})
|
|
@@ -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
|
+
});
|