switchroom 0.13.29 → 0.13.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +78 -11
- package/telegram-plugin/gateway/gateway.ts +143 -39
- package/telegram-plugin/secret-detect/vault-write.ts +84 -6
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +138 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +22 -8
- package/telegram-plugin/tests/vault-write-posture.test.ts +108 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -47436,8 +47436,8 @@ var {
|
|
|
47436
47436
|
} = import__.default;
|
|
47437
47437
|
|
|
47438
47438
|
// src/build-info.ts
|
|
47439
|
-
var VERSION = "0.13.
|
|
47440
|
-
var COMMIT_SHA = "
|
|
47439
|
+
var VERSION = "0.13.31";
|
|
47440
|
+
var COMMIT_SHA = "061eead1";
|
|
47441
47441
|
|
|
47442
47442
|
// src/cli/agent.ts
|
|
47443
47443
|
init_source();
|
package/package.json
CHANGED
|
@@ -25142,6 +25142,34 @@ async function getViaBrokerStructured(key, opts) {
|
|
|
25142
25142
|
}
|
|
25143
25143
|
return { kind: "unreachable", msg: "unexpected broker response shape" };
|
|
25144
25144
|
}
|
|
25145
|
+
async function putViaBroker(key, entry, opts) {
|
|
25146
|
+
const token = opts?.token;
|
|
25147
|
+
const passphrase = opts?.passphrase;
|
|
25148
|
+
const attestViaPosture = opts?.attest_via_posture === true;
|
|
25149
|
+
const result = await rpc({
|
|
25150
|
+
v: 1,
|
|
25151
|
+
op: "put",
|
|
25152
|
+
key,
|
|
25153
|
+
entry,
|
|
25154
|
+
...token ? { token } : {},
|
|
25155
|
+
...passphrase ? { passphrase } : {},
|
|
25156
|
+
...attestViaPosture ? { attest_via_posture: true } : {}
|
|
25157
|
+
}, opts);
|
|
25158
|
+
if (result.kind === "unreachable") {
|
|
25159
|
+
return { kind: "unreachable", msg: result.msg };
|
|
25160
|
+
}
|
|
25161
|
+
const resp = result.resp;
|
|
25162
|
+
if (resp.ok && "put" in resp) {
|
|
25163
|
+
return { kind: "ok" };
|
|
25164
|
+
}
|
|
25165
|
+
if (!resp.ok) {
|
|
25166
|
+
if (resp.code === "UNKNOWN_KEY") {
|
|
25167
|
+
return { kind: "not_found", code: resp.code, msg: resp.msg };
|
|
25168
|
+
}
|
|
25169
|
+
return { kind: "denied", code: resp.code, msg: resp.msg };
|
|
25170
|
+
}
|
|
25171
|
+
return { kind: "unreachable", msg: "unexpected broker response shape" };
|
|
25172
|
+
}
|
|
25145
25173
|
var DEFAULT_TIMEOUT_MS3 = 2000, LEGACY_SOCKET_PATH, OPERATOR_SOCKET_PATH;
|
|
25146
25174
|
var init_client2 = __esm(() => {
|
|
25147
25175
|
init_protocol2();
|
|
@@ -46209,7 +46237,19 @@ function maskToken2(s) {
|
|
|
46209
46237
|
}
|
|
46210
46238
|
|
|
46211
46239
|
// secret-detect/vault-write.ts
|
|
46240
|
+
init_client2();
|
|
46212
46241
|
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
46242
|
+
var defaultVaultWritePosture = async (slug, value, deps) => {
|
|
46243
|
+
const put = deps?.putViaBroker ?? putViaBroker;
|
|
46244
|
+
const resolveSocket = deps?.resolveBrokerSocketPath ?? resolveBrokerSocketPath;
|
|
46245
|
+
const socket = resolveSocket();
|
|
46246
|
+
const result = await put(slug, { kind: "string", value }, { socket, attest_via_posture: true, timeoutMs: 1e4 });
|
|
46247
|
+
if (result.kind === "ok") {
|
|
46248
|
+
return { ok: true, output: `sent (key: ${slug})` };
|
|
46249
|
+
}
|
|
46250
|
+
const prefix = result.kind === "unreachable" ? "VAULT-BROKER-UNREACHABLE" : result.kind === "denied" ? `VAULT-BROKER-DENIED (${result.code})` : `VAULT-BROKER-${result.code}`;
|
|
46251
|
+
return { ok: false, output: `${prefix}: ${result.msg}` };
|
|
46252
|
+
};
|
|
46213
46253
|
var defaultVaultWrite = (slug, value, passphrase) => {
|
|
46214
46254
|
const env = {
|
|
46215
46255
|
...process.env,
|
|
@@ -48464,10 +48504,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48464
48504
|
}
|
|
48465
48505
|
|
|
48466
48506
|
// ../src/build-info.ts
|
|
48467
|
-
var VERSION = "0.13.
|
|
48468
|
-
var COMMIT_SHA = "
|
|
48469
|
-
var COMMIT_DATE = "2026-05-
|
|
48470
|
-
var LATEST_PR =
|
|
48507
|
+
var VERSION = "0.13.31";
|
|
48508
|
+
var COMMIT_SHA = "061eead1";
|
|
48509
|
+
var COMMIT_DATE = "2026-05-24T13:21:09Z";
|
|
48510
|
+
var LATEST_PR = 1736;
|
|
48471
48511
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
48472
48512
|
|
|
48473
48513
|
// gateway/boot-version.ts
|
|
@@ -49450,6 +49490,22 @@ function purgeReactionTracking(key, endingTurn) {
|
|
|
49450
49490
|
}
|
|
49451
49491
|
}
|
|
49452
49492
|
}
|
|
49493
|
+
function releaseTurnBufferGate(key) {
|
|
49494
|
+
if (!activeTurnStartedAt.has(key))
|
|
49495
|
+
return;
|
|
49496
|
+
activeTurnStartedAt.delete(key);
|
|
49497
|
+
shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted: true });
|
|
49498
|
+
if (activeTurnStartedAt.size === 0) {
|
|
49499
|
+
const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
49500
|
+
if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
|
|
49501
|
+
const fr = redeliverBufferedInbound(pendingInboundBuffer, selfAgentForFlush, (m) => ipcServer.sendToAgent(selfAgentForFlush, m), inboundSpool);
|
|
49502
|
+
if (fr.redelivered > 0) {
|
|
49503
|
+
process.stderr.write(`telegram gateway: reply-released-gate flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
|
|
49504
|
+
`);
|
|
49505
|
+
}
|
|
49506
|
+
}
|
|
49507
|
+
}
|
|
49508
|
+
}
|
|
49453
49509
|
function endCurrentTurnAtomic(turn) {
|
|
49454
49510
|
if (currentTurn !== turn)
|
|
49455
49511
|
return;
|
|
@@ -51328,6 +51384,7 @@ ${url}`;
|
|
|
51328
51384
|
turn.finalAnswerDelivered = true;
|
|
51329
51385
|
finalizeStatusReaction(chat_id, threadId, "done");
|
|
51330
51386
|
}
|
|
51387
|
+
releaseTurnBufferGate(statusKey(chat_id, threadId));
|
|
51331
51388
|
}
|
|
51332
51389
|
process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
|
|
51333
51390
|
`);
|
|
@@ -51465,6 +51522,11 @@ async function executeStreamReply(args) {
|
|
|
51465
51522
|
})) {
|
|
51466
51523
|
turn.finalAnswerDelivered = true;
|
|
51467
51524
|
}
|
|
51525
|
+
{
|
|
51526
|
+
const sChat = args.chat_id;
|
|
51527
|
+
const sThread = resolveThreadId(sChat, args.message_thread_id);
|
|
51528
|
+
releaseTurnBufferGate(statusKey(sChat, sThread));
|
|
51529
|
+
}
|
|
51468
51530
|
return { content: [{ type: "text", text: `${result.status} (id: ${result.messageId ?? "pending"})` }] };
|
|
51469
51531
|
}
|
|
51470
51532
|
async function executeProgressUpdate(args) {
|
|
@@ -55370,15 +55432,20 @@ async function handleVaultRequestSaveCallback(ctx, data) {
|
|
|
55370
55432
|
}
|
|
55371
55433
|
if (action === "save") {
|
|
55372
55434
|
await ctx.answerCallbackQuery({ text: "\u23F3 Saving\u2026" }).catch(() => {});
|
|
55373
|
-
|
|
55374
|
-
if (
|
|
55375
|
-
|
|
55376
|
-
|
|
55435
|
+
let write;
|
|
55436
|
+
if (VAULT_APPROVAL_AUTH_MODE === "telegram-id") {
|
|
55437
|
+
write = await defaultVaultWritePosture(pending2.key, pending2.value);
|
|
55438
|
+
} else {
|
|
55439
|
+
const cached = vaultPassphraseCache.get(pending2.chat_id);
|
|
55440
|
+
if (!cached || cached.expiresAt <= Date.now()) {
|
|
55441
|
+
if (pending2.card_message_id != null) {
|
|
55442
|
+
await ctx.api.editMessageText(pending2.chat_id, pending2.card_message_id, `\uD83D\uDD12 <b>Passphrase not cached for this chat.</b> Run <code>/vault unlock</code> (or any /vault command) to cache it, then tap Save again on the next card.`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
55443
|
+
}
|
|
55444
|
+
pendingVaultRequestSaves.delete(stageId);
|
|
55445
|
+
return;
|
|
55377
55446
|
}
|
|
55378
|
-
|
|
55379
|
-
return;
|
|
55447
|
+
write = defaultVaultWrite(pending2.key, pending2.value, cached.passphrase);
|
|
55380
55448
|
}
|
|
55381
|
-
const write = defaultVaultWrite(pending2.key, pending2.value, cached.passphrase);
|
|
55382
55449
|
if (!write.ok) {
|
|
55383
55450
|
const parsed = parseVaultCliError(write.output);
|
|
55384
55451
|
const rendered = renderVaultCliError(parsed, { verb: "save", key: pending2.key });
|
|
@@ -332,7 +332,7 @@ import {
|
|
|
332
332
|
import { runPipeline } from '../secret-detect/pipeline.js'
|
|
333
333
|
import { StagingMap } from '../secret-detect/staging.js'
|
|
334
334
|
import { maskToken } from '../secret-detect/mask.js'
|
|
335
|
-
import { defaultVaultWrite, defaultVaultList } from '../secret-detect/vault-write.js'
|
|
335
|
+
import { defaultVaultWrite, defaultVaultList, defaultVaultWritePosture } from '../secret-detect/vault-write.js'
|
|
336
336
|
import { parseVaultCliError, renderVaultCliError } from '../secret-detect/vault-error.js'
|
|
337
337
|
import { recentDenialsFromAuditLog, type RecentDenial } from './recent-denials.js'
|
|
338
338
|
import { detectSecrets } from '../secret-detect/index.js'
|
|
@@ -1412,6 +1412,78 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
1412
1412
|
}
|
|
1413
1413
|
}
|
|
1414
1414
|
|
|
1415
|
+
/**
|
|
1416
|
+
* Narrow buffer-gate release. Clears the per-key
|
|
1417
|
+
* `activeTurnStartedAt` entry and triggers the held-inbound flush
|
|
1418
|
+
* if the fleet went idle, WITHOUT touching the reaction
|
|
1419
|
+
* controller, the active-reaction message-id, or the typing loop.
|
|
1420
|
+
*
|
|
1421
|
+
* Why split from `purgeReactionTracking`. #1718's contract keeps
|
|
1422
|
+
* `activeStatusReactions[key]` alive across the turn so the
|
|
1423
|
+
* working-state ladder can re-paint on every tool/thinking event
|
|
1424
|
+
* (and the steer-vs-queue logic at the inbound handler reads the
|
|
1425
|
+
* controller — gateway.ts:8322-8323 — to classify mid-turn
|
|
1426
|
+
* messages). Wiping the controller mid-turn would either collapse
|
|
1427
|
+
* the ladder to 👍 prematurely (#1713 regression) or break the
|
|
1428
|
+
* steer detection.
|
|
1429
|
+
*
|
|
1430
|
+
* The BUFFER gate (`activeTurnStartedAt`) is a separate concern:
|
|
1431
|
+
* it gates `shouldBufferInbound` (gateway.ts:8603) and the
|
|
1432
|
+
* "claude is idle" flush at `purgeReactionTracking`'s tail. The
|
|
1433
|
+
* #1728/#1729 fix released both halves together by gating on
|
|
1434
|
+
* `isFinalAnswerReply`, but a trivial-prompt reply that sets
|
|
1435
|
+
* `disable_notification: true` and is < 200 chars (e.g. the model
|
|
1436
|
+
* mis-classifies "4" as an interim ack) returns false from
|
|
1437
|
+
* `isFinalAnswerReply`, so neither half releases and the gate
|
|
1438
|
+
* wedges (v0.13.30 UAT regression — every subsequent inbound logs
|
|
1439
|
+
* `held mid-turn ... will flush on turn-complete` forever).
|
|
1440
|
+
*
|
|
1441
|
+
* `releaseTurnBufferGate` is called from `executeReply` on EVERY
|
|
1442
|
+
* successful reply finalize — regardless of `isFinalAnswerReply` —
|
|
1443
|
+
* so the buffer gate releases independently of the reaction
|
|
1444
|
+
* state. The reaction controller stays for #1713's bidirectional
|
|
1445
|
+
* ladder + steer detection; only the gate flips.
|
|
1446
|
+
*
|
|
1447
|
+
* Idempotent: a second release is a no-op `.delete()` on an
|
|
1448
|
+
* already-empty key.
|
|
1449
|
+
*
|
|
1450
|
+
* @internal exported only via the `gateway.ts` module — used by
|
|
1451
|
+
* `executeReply`'s post-send block and by tests via source-level
|
|
1452
|
+
* pinning in `vault-approval-posture.test.ts` / wedge-guard suites.
|
|
1453
|
+
*/
|
|
1454
|
+
function releaseTurnBufferGate(key: string): void {
|
|
1455
|
+
if (!activeTurnStartedAt.has(key)) return
|
|
1456
|
+
activeTurnStartedAt.delete(key)
|
|
1457
|
+
// Shadow trace so the structural turn-end metric still records.
|
|
1458
|
+
// outboundEmitted=true is correct here — we only reach this from
|
|
1459
|
+
// executeReply AFTER an outbound landed.
|
|
1460
|
+
shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted: true })
|
|
1461
|
+
|
|
1462
|
+
// Mirror the deterministic-delivery flush from
|
|
1463
|
+
// `purgeReactionTracking` (gateway.ts:1376-1399). When the fleet
|
|
1464
|
+
// hits zero-active-turns, drain any held inbound. This is the
|
|
1465
|
+
// load-bearing wedge fix: the gate that pinned msg 1874+ in
|
|
1466
|
+
// test-harness's 13:02 UAT now opens after the reply.
|
|
1467
|
+
if (activeTurnStartedAt.size === 0) {
|
|
1468
|
+
const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
1469
|
+
if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
|
|
1470
|
+
const fr = redeliverBufferedInbound(
|
|
1471
|
+
pendingInboundBuffer,
|
|
1472
|
+
selfAgentForFlush,
|
|
1473
|
+
(m) => ipcServer.sendToAgent(selfAgentForFlush, m),
|
|
1474
|
+
inboundSpool,
|
|
1475
|
+
)
|
|
1476
|
+
if (fr.redelivered > 0) {
|
|
1477
|
+
process.stderr.write(
|
|
1478
|
+
`telegram gateway: reply-released-gate flushed ${fr.redelivered}/${fr.drained} ` +
|
|
1479
|
+
`held inbound for ${selfAgentForFlush}` +
|
|
1480
|
+
`${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ''}\n`,
|
|
1481
|
+
)
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1415
1487
|
/**
|
|
1416
1488
|
* Atomic null-and-purge for a wedged turn. Every site that ends a
|
|
1417
1489
|
* turn by nulling `currentTurn` MUST also clear the turn's statusKey
|
|
@@ -4975,6 +5047,24 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4975
5047
|
// observable side effects that #1718 deferred unconditionally.
|
|
4976
5048
|
finalizeStatusReaction(chat_id, threadId, 'done')
|
|
4977
5049
|
}
|
|
5050
|
+
// v0.13.30 follow-up — release the buffer gate on EVERY reply
|
|
5051
|
+
// finalize, not just on `isFinalAnswerReply`. The narrow
|
|
5052
|
+
// `finalizeStatusReaction` path above misses short replies that
|
|
5053
|
+
// set `disable_notification: true` (the model mis-classifies a
|
|
5054
|
+
// genuine answer as an interim ack — e.g. "4" for "what's
|
|
5055
|
+
// 2+2"). Pre-fix the gate stayed set forever and every later
|
|
5056
|
+
// inbound logged `held mid-turn ... will flush on turn-
|
|
5057
|
+
// complete` — but turn-complete never came because Claude
|
|
5058
|
+
// Code's `turn_duration` system event doesn't reliably land
|
|
5059
|
+
// for trivial-prompt turns. v0.13.30 UAT showed the regression
|
|
5060
|
+
// (msg 1873 reply at 13:02:46, msg 1874 held at 13:03:04, gate
|
|
5061
|
+
// never released).
|
|
5062
|
+
//
|
|
5063
|
+
// The reaction controller stays alive (preserves #1713
|
|
5064
|
+
// bidirectional ladder + the steer-vs-queue logic at
|
|
5065
|
+
// gateway.ts:8322 which reads `activeStatusReactions`). Only
|
|
5066
|
+
// the buffer gate flips.
|
|
5067
|
+
releaseTurnBufferGate(statusKey(chat_id, threadId))
|
|
4978
5068
|
}
|
|
4979
5069
|
|
|
4980
5070
|
process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(',')}] chunks=${chunks.length}\n`)
|
|
@@ -5231,6 +5321,17 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5231
5321
|
) {
|
|
5232
5322
|
turn.finalAnswerDelivered = true
|
|
5233
5323
|
}
|
|
5324
|
+
// v0.13.30 follow-up — release the buffer gate on every successful
|
|
5325
|
+
// stream_reply too. Same rationale as executeReply: short replies
|
|
5326
|
+
// with `disable_notification: true` would otherwise wedge the gate
|
|
5327
|
+
// forever. `result.status` is always 'updated' | 'finalized'
|
|
5328
|
+
// (stream-reply-handler.ts:305) at this point — earlier failures
|
|
5329
|
+
// throw or return before reaching here.
|
|
5330
|
+
{
|
|
5331
|
+
const sChat = args.chat_id as string
|
|
5332
|
+
const sThread = resolveThreadId(sChat, args.message_thread_id as string | undefined)
|
|
5333
|
+
releaseTurnBufferGate(statusKey(sChat, sThread))
|
|
5334
|
+
}
|
|
5234
5335
|
return { content: [{ type: 'text', text: `${result.status} (id: ${result.messageId ?? 'pending'})` }] }
|
|
5235
5336
|
}
|
|
5236
5337
|
|
|
@@ -11774,47 +11875,50 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
11774
11875
|
// stale "spinning" state on the button while we run the write.
|
|
11775
11876
|
await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
|
|
11776
11877
|
|
|
11777
|
-
// #1115 follow-up:
|
|
11778
|
-
//
|
|
11779
|
-
//
|
|
11780
|
-
//
|
|
11781
|
-
//
|
|
11782
|
-
//
|
|
11783
|
-
//
|
|
11784
|
-
//
|
|
11785
|
-
//
|
|
11786
|
-
//
|
|
11787
|
-
//
|
|
11788
|
-
//
|
|
11789
|
-
|
|
11790
|
-
|
|
11791
|
-
|
|
11792
|
-
|
|
11793
|
-
|
|
11794
|
-
|
|
11795
|
-
|
|
11796
|
-
|
|
11797
|
-
|
|
11798
|
-
|
|
11799
|
-
|
|
11800
|
-
|
|
11801
|
-
|
|
11802
|
-
|
|
11803
|
-
|
|
11804
|
-
|
|
11805
|
-
|
|
11806
|
-
|
|
11878
|
+
// #1115 follow-up: the save-approve flow now mirrors the access-
|
|
11879
|
+
// approve flow under telegram-id mode — broker `put` accepts
|
|
11880
|
+
// `attest_via_posture: true` (server.ts:1448-1500), so the
|
|
11881
|
+
// gateway can attest the write without a cached passphrase.
|
|
11882
|
+
// Closes the UX gap where tapping Save surfaced a misleading
|
|
11883
|
+
// "🔒 Vault is locked" message even when the broker had been
|
|
11884
|
+
// auto-unlocked at boot.
|
|
11885
|
+
//
|
|
11886
|
+
// Branch: under telegram-id mode use the posture-attested put;
|
|
11887
|
+
// under passphrase mode keep the existing cached-passphrase +
|
|
11888
|
+
// shell-to-CLI path (operator must `/vault unlock` once per
|
|
11889
|
+
// chat session to populate `vaultPassphraseCache`).
|
|
11890
|
+
let write: { ok: boolean; output: string }
|
|
11891
|
+
if (VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
|
|
11892
|
+
// Posture-attested broker put. No passphrase needed. The broker
|
|
11893
|
+
// verifies (a) telegram-id mode, (b) per-agent peer, (c) broker
|
|
11894
|
+
// unlocked — see server.ts:1448-1500.
|
|
11895
|
+
write = await defaultVaultWritePosture(pending.key, pending.value)
|
|
11896
|
+
} else {
|
|
11897
|
+
// Passphrase mode — fetch the cached passphrase for this chat.
|
|
11898
|
+
// If the gateway hasn't seen the user unlock the vault yet, we
|
|
11899
|
+
// can't attest the write — surface the unlock prompt.
|
|
11900
|
+
const cached = vaultPassphraseCache.get(pending.chat_id)
|
|
11901
|
+
if (!cached || cached.expiresAt <= Date.now()) {
|
|
11902
|
+
if (pending.card_message_id != null) {
|
|
11903
|
+
await ctx.api
|
|
11904
|
+
.editMessageText(
|
|
11905
|
+
pending.chat_id,
|
|
11906
|
+
pending.card_message_id,
|
|
11907
|
+
`🔒 <b>Passphrase not cached for this chat.</b> Run <code>/vault unlock</code> (or any /vault command) to cache it, then tap Save again on the next card.`,
|
|
11908
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
11909
|
+
)
|
|
11910
|
+
.catch(() => {})
|
|
11911
|
+
}
|
|
11912
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
11913
|
+
return
|
|
11807
11914
|
}
|
|
11808
|
-
|
|
11809
|
-
|
|
11915
|
+
// defaultVaultWrite spawns `switchroom vault set <key>` with the
|
|
11916
|
+
// passphrase env set; the CLI forwards the passphrase to the
|
|
11917
|
+
// broker put as operator-attestation (#969 P1a), which authorizes
|
|
11918
|
+
// new-key creation.
|
|
11919
|
+
write = defaultVaultWrite(pending.key, pending.value, cached.passphrase)
|
|
11810
11920
|
}
|
|
11811
11921
|
|
|
11812
|
-
// Run the write. defaultVaultWrite spawns `switchroom vault set
|
|
11813
|
-
// <key>` with the passphrase env set; the CLI in turn forwards the
|
|
11814
|
-
// passphrase to the broker put as operator-attestation (#969 P1a),
|
|
11815
|
-
// which authorizes new-key creation.
|
|
11816
|
-
const write = defaultVaultWrite(pending.key, pending.value, cached.passphrase)
|
|
11817
|
-
|
|
11818
11922
|
if (!write.ok) {
|
|
11819
11923
|
// Route through the structured-error renderer from #969 P0b so
|
|
11820
11924
|
// failures show the actionable host hint instead of a raw blob.
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Programmatic vault write from the Telegram plugin path.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* set` with SWITCHROOM_VAULT_PASSPHRASE in env and the secret piped on
|
|
6
|
-
* stdin) so we don't have to import and open the vault directly from this
|
|
7
|
-
* subprocess.
|
|
4
|
+
* Two flavors:
|
|
8
5
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* 1. `defaultVaultWrite(slug, value, passphrase)` — shell-out to
|
|
7
|
+
* `switchroom vault set`. The CLI forwards the passphrase to the
|
|
8
|
+
* broker as operator-attestation (#969 P1a). Used by passphrase-
|
|
9
|
+
* mode approval flows where the gateway cached the operator's
|
|
10
|
+
* passphrase via /vault unlock.
|
|
11
|
+
*
|
|
12
|
+
* 2. `defaultVaultWritePosture(slug, value)` — direct broker call via
|
|
13
|
+
* `putViaBroker(..., attest_via_posture: true)`. Used by
|
|
14
|
+
* telegram-id-mode approval flows where the broker auto-unlocked
|
|
15
|
+
* at boot and the gateway never needs the passphrase
|
|
16
|
+
* (`vault.broker.approvalAuth: telegram-id` in switchroom.yaml).
|
|
17
|
+
* Closes the UX gap where tapping Save on a `vault_request_save`
|
|
18
|
+
* card surfaced a misleading "🔒 Vault is locked" message when
|
|
19
|
+
* the operator hadn't run /vault unlock in that chat — even
|
|
20
|
+
* though the broker had been unlocked for hours. The tracked
|
|
21
|
+
* follow-up of #1115; broker side has supported the flag since
|
|
22
|
+
* PR #1115 follow-up rev 3.
|
|
23
|
+
*
|
|
24
|
+
* Exposed as pure functions for testability: callers inject the spawn /
|
|
25
|
+
* broker helper in tests to avoid needing a real vault on disk.
|
|
11
26
|
*/
|
|
12
27
|
import { execFileSync } from 'node:child_process'
|
|
28
|
+
import { putViaBroker, resolveBrokerSocketPath } from '../../src/vault/broker/client.js'
|
|
13
29
|
|
|
14
30
|
export interface VaultWriteResult {
|
|
15
31
|
ok: boolean
|
|
@@ -22,6 +38,68 @@ export type VaultWriteFn = (
|
|
|
22
38
|
passphrase: string,
|
|
23
39
|
) => VaultWriteResult
|
|
24
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Posture-attested vault write — no passphrase required. Returns the
|
|
43
|
+
* same `VaultWriteResult` shape as `defaultVaultWrite` for drop-in
|
|
44
|
+
* substitution at the gateway save / defer / auto-save callsites.
|
|
45
|
+
*
|
|
46
|
+
* Caller MUST verify `vault.broker.approvalAuth === 'telegram-id'` in
|
|
47
|
+
* config before invoking — the broker will reject `attest_via_posture`
|
|
48
|
+
* under passphrase mode with `attest_via_posture requires
|
|
49
|
+
* vault.broker.approvalAuth: telegram-id` (server.ts:1500). The
|
|
50
|
+
* gateway's `VAULT_APPROVAL_AUTH_MODE` flag is the canonical signal.
|
|
51
|
+
*
|
|
52
|
+
* Returns `{ ok: false, output: ... }` on any broker error so the
|
|
53
|
+
* gateway's existing parseVaultCliError / renderVaultCliError path
|
|
54
|
+
* still works.
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* Test-injection seam for `defaultVaultWritePosture`. Production
|
|
58
|
+
* callers omit and get the live broker client; tests pass mock
|
|
59
|
+
* functions to avoid `vi.mock` / `mock.module` (which don't
|
|
60
|
+
* cross-runtime cleanly — vitest's `vi.mock` and bun:test's
|
|
61
|
+
* `mock.module` need separate wiring and shadow other tests' module
|
|
62
|
+
* imports if not carefully isolated).
|
|
63
|
+
*/
|
|
64
|
+
export interface VaultWritePostureDeps {
|
|
65
|
+
putViaBroker?: typeof putViaBroker
|
|
66
|
+
resolveBrokerSocketPath?: typeof resolveBrokerSocketPath
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type VaultWritePostureFn = (
|
|
70
|
+
slug: string,
|
|
71
|
+
value: string,
|
|
72
|
+
deps?: VaultWritePostureDeps,
|
|
73
|
+
) => Promise<VaultWriteResult>
|
|
74
|
+
|
|
75
|
+
export const defaultVaultWritePosture: VaultWritePostureFn = async (
|
|
76
|
+
slug,
|
|
77
|
+
value,
|
|
78
|
+
deps,
|
|
79
|
+
) => {
|
|
80
|
+
const put = deps?.putViaBroker ?? putViaBroker
|
|
81
|
+
const resolveSocket = deps?.resolveBrokerSocketPath ?? resolveBrokerSocketPath
|
|
82
|
+
const socket = resolveSocket()
|
|
83
|
+
const result = await put(
|
|
84
|
+
slug,
|
|
85
|
+
{ kind: 'string', value },
|
|
86
|
+
{ socket, attest_via_posture: true, timeoutMs: 10000 },
|
|
87
|
+
)
|
|
88
|
+
if (result.kind === 'ok') {
|
|
89
|
+
return { ok: true, output: `sent (key: ${slug})` }
|
|
90
|
+
}
|
|
91
|
+
// Mirror the CLI's error format so the existing parseVaultCliError
|
|
92
|
+
// / renderVaultCliError stack handles broker-mediated failures
|
|
93
|
+
// without a special branch.
|
|
94
|
+
const prefix =
|
|
95
|
+
result.kind === 'unreachable'
|
|
96
|
+
? 'VAULT-BROKER-UNREACHABLE'
|
|
97
|
+
: result.kind === 'denied'
|
|
98
|
+
? `VAULT-BROKER-DENIED (${result.code})`
|
|
99
|
+
: `VAULT-BROKER-${result.code}`
|
|
100
|
+
return { ok: false, output: `${prefix}: ${result.msg}` }
|
|
101
|
+
}
|
|
102
|
+
|
|
25
103
|
export type VaultListFn = (passphrase: string) => { ok: boolean; keys: string[] }
|
|
26
104
|
|
|
27
105
|
export const defaultVaultWrite: VaultWriteFn = (slug, value, passphrase) => {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard for the v0.13.30→v0.13.31 wedge fix: the buffer
|
|
3
|
+
* gate (`activeTurnStartedAt`) must release on EVERY successful
|
|
4
|
+
* reply / stream_reply finalize — not just on `isFinalAnswerReply`.
|
|
5
|
+
*
|
|
6
|
+
* Surfaced 2026-05-24 via the v0.13.30 UAT canary:
|
|
7
|
+
* 13:02:46 reply finalized for msg 1873 (charCount=67)
|
|
8
|
+
* 13:03:04 msg 1874 — held mid-turn (gate STILL open)
|
|
9
|
+
* 13:03:40 msg 1875 — held mid-turn (depth=2)
|
|
10
|
+
* 13:04:19 msg 1876 — held mid-turn (depth=3)
|
|
11
|
+
*
|
|
12
|
+
* Root cause: the trivial-prompt reply used `disable_notification:
|
|
13
|
+
* true` and was < 200 chars (the model classified "4" as an interim
|
|
14
|
+
* ack), so `isFinalAnswerReply` returned false, the
|
|
15
|
+
* `finalizeStatusReaction` gate in `executeReply` short-circuited,
|
|
16
|
+
* and the buffer gate stayed set. Pre-#1718 the gate released on
|
|
17
|
+
* every reply (via `endStatusReaction → purgeReactionTracking`);
|
|
18
|
+
* #1718 deferred everything to `turn_end`, then #1729 partially
|
|
19
|
+
* restored via `isFinalAnswerReply`-gated finalize. This fix
|
|
20
|
+
* decouples the buffer-gate release from the reaction-state
|
|
21
|
+
* finalize: every successful reply releases the gate, the reaction
|
|
22
|
+
* controller stays alive (preserves #1713 bidirectional ladder +
|
|
23
|
+
* the steer-vs-queue logic).
|
|
24
|
+
*
|
|
25
|
+
* The gateway IIFE is too entangled to instantiate in-process; we
|
|
26
|
+
* do source-level assertions like `reply-terminal-reaction.test.ts`
|
|
27
|
+
* does. If a future commit regresses the contract (re-narrows the
|
|
28
|
+
* gate release, or removes the helper), these assertions trip.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest'
|
|
32
|
+
import { readFileSync } from 'node:fs'
|
|
33
|
+
import { resolve } from 'node:path'
|
|
34
|
+
|
|
35
|
+
const gatewaySrc = readFileSync(
|
|
36
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
37
|
+
'utf-8',
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
describe('buffer-gate release decoupled from final-answer classification', () => {
|
|
41
|
+
// Extract the helper's docstring (everything between the matching
|
|
42
|
+
// `/**` block above `function releaseTurnBufferGate` and the
|
|
43
|
+
// function declaration). The body slice (used by other tests) is
|
|
44
|
+
// separate — see fnBody().
|
|
45
|
+
function fnDocstring(): string {
|
|
46
|
+
const beforeFn = gatewaySrc.split('function releaseTurnBufferGate')[0] ?? ''
|
|
47
|
+
const lastBlockOpen = beforeFn.lastIndexOf('/**')
|
|
48
|
+
if (lastBlockOpen < 0) return ''
|
|
49
|
+
return beforeFn.slice(lastBlockOpen)
|
|
50
|
+
}
|
|
51
|
+
function fnBody(): string {
|
|
52
|
+
// The function body — everything between `function
|
|
53
|
+
// releaseTurnBufferGate(...): void {` and its matching `}`. Use
|
|
54
|
+
// a simple brace-balance over the slice from open-brace onward.
|
|
55
|
+
const afterDecl = gatewaySrc.split('function releaseTurnBufferGate')[1] ?? ''
|
|
56
|
+
const openIdx = afterDecl.indexOf('{')
|
|
57
|
+
if (openIdx < 0) return ''
|
|
58
|
+
let depth = 0
|
|
59
|
+
for (let i = openIdx; i < afterDecl.length; i++) {
|
|
60
|
+
const ch = afterDecl[i]
|
|
61
|
+
if (ch === '{') depth++
|
|
62
|
+
else if (ch === '}') {
|
|
63
|
+
depth--
|
|
64
|
+
if (depth === 0) return afterDecl.slice(openIdx, i + 1)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return afterDecl.slice(openIdx)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
it('declares a narrow `releaseTurnBufferGate` helper (not the full purgeReactionTracking)', () => {
|
|
71
|
+
expect(gatewaySrc).toMatch(/function releaseTurnBufferGate\(key: string\): void/)
|
|
72
|
+
// The helper docstring must explain WHY split from
|
|
73
|
+
// purgeReactionTracking — future readers need to know.
|
|
74
|
+
const doc = fnDocstring()
|
|
75
|
+
expect(doc).toMatch(/#1713/)
|
|
76
|
+
expect(doc).toMatch(/steer-vs-queue/)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('releaseTurnBufferGate ONLY clears activeTurnStartedAt + flushes; does NOT touch activeStatusReactions', () => {
|
|
80
|
+
const body = fnBody()
|
|
81
|
+
expect(body).toMatch(/activeTurnStartedAt\.delete\(key\)/)
|
|
82
|
+
expect(body).toMatch(/pendingInboundBuffer/)
|
|
83
|
+
// Critical regression guard: the helper must NOT touch the
|
|
84
|
+
// reaction controller, else #1713's bidirectional ladder
|
|
85
|
+
// collapses to 👍 mid-turn.
|
|
86
|
+
expect(body).not.toMatch(/activeStatusReactions\.delete/)
|
|
87
|
+
expect(body).not.toMatch(/activeReactionMsgIds\.delete/)
|
|
88
|
+
// Also must NOT call finalizeStatusReaction or
|
|
89
|
+
// purgeReactionTracking (both would clear the controller).
|
|
90
|
+
expect(body).not.toMatch(/finalizeStatusReaction\(/)
|
|
91
|
+
expect(body).not.toMatch(/purgeReactionTracking\(/)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('executeReply calls releaseTurnBufferGate OUTSIDE the isFinalAnswerReply branch', () => {
|
|
95
|
+
// Slice the executeReply post-send block (between the anchor
|
|
96
|
+
// comments and the next exported function).
|
|
97
|
+
const post = gatewaySrc.split("fresh sendMessage from reply tool is a user-visible")[1] ?? ''
|
|
98
|
+
const slice = post.split('\nasync function ')[0] ?? ''
|
|
99
|
+
// The narrow `isFinalAnswerReply`-gated finalize MUST stay (it
|
|
100
|
+
// emits the 👍 reaction on the final-answer happy path).
|
|
101
|
+
expect(slice).toMatch(/isFinalAnswerReply\(/)
|
|
102
|
+
expect(slice).toMatch(/finalizeStatusReaction\(/)
|
|
103
|
+
// The new unconditional buffer-gate release must ALSO be
|
|
104
|
+
// present and must be OUTSIDE the isFinalAnswerReply branch
|
|
105
|
+
// (so trivial-prompt non-notification replies still release
|
|
106
|
+
// the gate).
|
|
107
|
+
expect(slice).toMatch(/releaseTurnBufferGate\(statusKey\(chat_id, threadId\)\)/)
|
|
108
|
+
// Structural check: the release must appear AFTER the
|
|
109
|
+
// isFinalAnswerReply block's closing brace but BEFORE the
|
|
110
|
+
// post-send block ends. Easiest pin: it must NOT be inside the
|
|
111
|
+
// `if (turn != null && isFinalAnswerReply(...))` block.
|
|
112
|
+
const gateBlockOpen = slice.indexOf('if (turn != null && isFinalAnswerReply(')
|
|
113
|
+
const gateBlockClose = slice.indexOf('}', gateBlockOpen)
|
|
114
|
+
const releaseIdx = slice.indexOf('releaseTurnBufferGate(')
|
|
115
|
+
expect(gateBlockOpen).toBeGreaterThan(-1)
|
|
116
|
+
expect(gateBlockClose).toBeGreaterThan(gateBlockOpen)
|
|
117
|
+
expect(releaseIdx).toBeGreaterThan(gateBlockClose)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('executeStreamReply calls releaseTurnBufferGate before its final return', () => {
|
|
121
|
+
const post =
|
|
122
|
+
gatewaySrc.split('async function executeStreamReply')[1]
|
|
123
|
+
?.split('\nasync function ')[0] ?? ''
|
|
124
|
+
expect(post).toMatch(/releaseTurnBufferGate\(statusKey\(/)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('the helper is invoked from executeReply / executeStreamReply only — not from new mid-turn paths', () => {
|
|
128
|
+
// Sanity: nothing else should call releaseTurnBufferGate. The
|
|
129
|
+
// helper is narrow on purpose. If future code adds new
|
|
130
|
+
// callsites that aren't reply-finalize, the steer-vs-queue
|
|
131
|
+
// semantics could drift.
|
|
132
|
+
const callMatches = gatewaySrc.match(/releaseTurnBufferGate\(/g) ?? []
|
|
133
|
+
// Definition + 2 callsites (executeReply, executeStreamReply) = 3.
|
|
134
|
+
// If this count grows the test catches it; reviewer must justify
|
|
135
|
+
// any new callsite.
|
|
136
|
+
expect(callMatches.length).toBe(3)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -121,20 +121,34 @@ describe('performVaultAccessApproval — broker-mediated attestation', () => {
|
|
|
121
121
|
})
|
|
122
122
|
})
|
|
123
123
|
|
|
124
|
-
describe('handleVaultRequestSaveCallback —
|
|
125
|
-
it('NO
|
|
124
|
+
describe('handleVaultRequestSaveCallback — posture-attested broker put (#1115 follow-up)', () => {
|
|
125
|
+
it('NO in-memory passphrase under telegram-id; routes the save through the broker via attest_via_posture', () => {
|
|
126
126
|
const fnBlock =
|
|
127
127
|
gatewaySrc
|
|
128
128
|
.split('async function handleVaultRequestSaveCallback')[1]
|
|
129
129
|
?.split('async function handleVaultDeferCallback')[0] ?? ''
|
|
130
|
-
// Regression guard
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
130
|
+
// Regression guard (original #1115 first-cut withdrawal): the
|
|
131
|
+
// handler must NOT pull a long-lived in-memory passphrase under
|
|
132
|
+
// telegram-id. That was a bypass surface — claude in the same
|
|
133
|
+
// container could exfiltrate it. The follow-up replaces it with
|
|
134
|
+
// a broker-IPC `attest_via_posture` call so the passphrase
|
|
135
|
+
// never leaves the broker process.
|
|
135
136
|
expect(fnBlock).not.toMatch(/AUTO_UNLOCK_PASSPHRASE/)
|
|
136
|
-
//
|
|
137
|
+
// The #1115 follow-up wiring: under telegram-id the handler
|
|
138
|
+
// calls `defaultVaultWritePosture` (posture-attested broker put,
|
|
139
|
+
// no passphrase). Under passphrase mode it keeps the legacy
|
|
140
|
+
// cached-passphrase + shell-out path.
|
|
141
|
+
expect(fnBlock).toMatch(/VAULT_APPROVAL_AUTH_MODE === 'telegram-id'/)
|
|
142
|
+
expect(fnBlock).toMatch(/defaultVaultWritePosture\(/)
|
|
143
|
+
// Passphrase-mode branch still present.
|
|
137
144
|
expect(fnBlock).toMatch(/vaultPassphraseCache\.get\(pending\.chat_id\)/)
|
|
145
|
+
expect(fnBlock).toMatch(/defaultVaultWrite\(pending\.key, pending\.value, cached\.passphrase\)/)
|
|
146
|
+
// The misleading pre-fix card text ("Vault is locked") must be
|
|
147
|
+
// rephrased — the broker IS unlocked under telegram-id; only
|
|
148
|
+
// the chat's passphrase cache needs warming up under passphrase
|
|
149
|
+
// mode. Pin the corrected wording so the wedge UX can't
|
|
150
|
+
// silently regress.
|
|
151
|
+
expect(fnBlock).toMatch(/Passphrase not cached for this chat/)
|
|
138
152
|
})
|
|
139
153
|
})
|
|
140
154
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `defaultVaultWritePosture` — the posture-attested
|
|
3
|
+
* vault write helper introduced by the #1115 follow-up.
|
|
4
|
+
*
|
|
5
|
+
* Contract pinned here:
|
|
6
|
+
* - Returns `{ ok: true, output }` on broker `kind: 'ok'`.
|
|
7
|
+
* - Returns `{ ok: false, output: <marker>: <msg> }` on each broker
|
|
8
|
+
* failure shape (unreachable / denied / not_found), with markers
|
|
9
|
+
* that the gateway's `parseVaultCliError` / `renderVaultCliError`
|
|
10
|
+
* stack can recognize (so failures still render the actionable
|
|
11
|
+
* host hint instead of a raw blob).
|
|
12
|
+
* - Forwards `attest_via_posture: true` on the put — the load-
|
|
13
|
+
* bearing flag the broker server (server.ts:1448-1500) gates on.
|
|
14
|
+
* - Forwards `kind: 'string'` entry shape verbatim (only kind the
|
|
15
|
+
* `vault_request_save` MCP tool emits).
|
|
16
|
+
*
|
|
17
|
+
* Test seam: the helper accepts an optional `deps` param so tests
|
|
18
|
+
* inject mock fns directly (no `vi.mock` / `mock.module`). This
|
|
19
|
+
* keeps the test runnable under both vitest AND bun:test without
|
|
20
|
+
* the module-cache cross-pollination that broke an earlier rev.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, it } from 'vitest'
|
|
24
|
+
import { defaultVaultWritePosture } from '../secret-detect/vault-write.js'
|
|
25
|
+
import type { PutResult } from '../../src/vault/broker/client.js'
|
|
26
|
+
|
|
27
|
+
type PutCall = {
|
|
28
|
+
slug: string
|
|
29
|
+
entry: { kind: 'string'; value: string } | { kind: 'binary'; value: string }
|
|
30
|
+
opts: { socket?: string; attest_via_posture?: boolean; timeoutMs?: number }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeDeps(putResult: PutResult, socket = '/run/switchroom/broker/sock'): {
|
|
34
|
+
deps: { putViaBroker: (...args: never[]) => Promise<PutResult>; resolveBrokerSocketPath: () => string }
|
|
35
|
+
calls: PutCall[]
|
|
36
|
+
} {
|
|
37
|
+
const calls: PutCall[] = []
|
|
38
|
+
const deps = {
|
|
39
|
+
putViaBroker: async (...args: never[]): Promise<PutResult> => {
|
|
40
|
+
const [slug, entry, opts] = args as unknown as [PutCall['slug'], PutCall['entry'], PutCall['opts']]
|
|
41
|
+
calls.push({ slug, entry, opts })
|
|
42
|
+
return putResult
|
|
43
|
+
},
|
|
44
|
+
resolveBrokerSocketPath: () => socket,
|
|
45
|
+
}
|
|
46
|
+
return { deps, calls }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('defaultVaultWritePosture', () => {
|
|
50
|
+
it('returns { ok: true } on broker `kind: ok`', async () => {
|
|
51
|
+
const { deps } = makeDeps({ kind: 'ok' })
|
|
52
|
+
const r = await defaultVaultWritePosture('forward-email/api-key', 'sk-value', deps)
|
|
53
|
+
expect(r.ok).toBe(true)
|
|
54
|
+
expect(r.output).toContain('forward-email/api-key')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('forwards attest_via_posture: true on the put RPC', async () => {
|
|
58
|
+
const { deps, calls } = makeDeps({ kind: 'ok' })
|
|
59
|
+
await defaultVaultWritePosture('ha/access-token', 'jwt-value', deps)
|
|
60
|
+
expect(calls).toHaveLength(1)
|
|
61
|
+
expect(calls[0].opts.attest_via_posture).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('forwards kind: string entry verbatim', async () => {
|
|
65
|
+
const { deps, calls } = makeDeps({ kind: 'ok' })
|
|
66
|
+
await defaultVaultWritePosture('some-key', 'some-value', deps)
|
|
67
|
+
expect(calls[0].slug).toBe('some-key')
|
|
68
|
+
expect(calls[0].entry).toEqual({ kind: 'string', value: 'some-value' })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns ok:false with VAULT-BROKER-UNREACHABLE prefix on unreachable', async () => {
|
|
72
|
+
const { deps } = makeDeps({ kind: 'unreachable', msg: 'connect ENOENT' })
|
|
73
|
+
const r = await defaultVaultWritePosture('k', 'v', deps)
|
|
74
|
+
expect(r.ok).toBe(false)
|
|
75
|
+
expect(r.output).toContain('VAULT-BROKER-UNREACHABLE')
|
|
76
|
+
expect(r.output).toContain('connect ENOENT')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns ok:false with VAULT-BROKER-DENIED prefix on denied', async () => {
|
|
80
|
+
const { deps } = makeDeps({
|
|
81
|
+
kind: 'denied',
|
|
82
|
+
code: 'DENIED',
|
|
83
|
+
msg: 'attest_via_posture requires telegram-id',
|
|
84
|
+
})
|
|
85
|
+
const r = await defaultVaultWritePosture('k', 'v', deps)
|
|
86
|
+
expect(r.ok).toBe(false)
|
|
87
|
+
expect(r.output).toContain('VAULT-BROKER-DENIED')
|
|
88
|
+
expect(r.output).toContain('DENIED')
|
|
89
|
+
expect(r.output).toContain('telegram-id')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('returns ok:false with VAULT-BROKER-UNKNOWN_KEY prefix on not_found', async () => {
|
|
93
|
+
const { deps } = makeDeps({
|
|
94
|
+
kind: 'not_found',
|
|
95
|
+
code: 'UNKNOWN_KEY',
|
|
96
|
+
msg: 'no such key',
|
|
97
|
+
})
|
|
98
|
+
const r = await defaultVaultWritePosture('k', 'v', deps)
|
|
99
|
+
expect(r.ok).toBe(false)
|
|
100
|
+
expect(r.output).toContain('VAULT-BROKER-UNKNOWN_KEY')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('resolves the broker socket via the injected resolveBrokerSocketPath', async () => {
|
|
104
|
+
const { deps, calls } = makeDeps({ kind: 'ok' }, '/tmp/test-socket')
|
|
105
|
+
await defaultVaultWritePosture('k', 'v', deps)
|
|
106
|
+
expect(calls[0].opts.socket).toBe('/tmp/test-socket')
|
|
107
|
+
})
|
|
108
|
+
})
|