switchroom 0.13.29 → 0.13.30

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.
@@ -47436,8 +47436,8 @@ var {
47436
47436
  } = import__.default;
47437
47437
 
47438
47438
  // src/build-info.ts
47439
- var VERSION = "0.13.29";
47440
- var COMMIT_SHA = "927abe08";
47439
+ var VERSION = "0.13.30";
47440
+ var COMMIT_SHA = "c544689a";
47441
47441
 
47442
47442
  // src/cli/agent.ts
47443
47443
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.29",
3
+ "version": "0.13.30",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.29";
48468
- var COMMIT_SHA = "927abe08";
48469
- var COMMIT_DATE = "2026-05-24T12:14:05Z";
48470
- var LATEST_PR = 1732;
48507
+ var VERSION = "0.13.30";
48508
+ var COMMIT_SHA = "c544689a";
48509
+ var COMMIT_DATE = "2026-05-24T12:56:26Z";
48510
+ var LATEST_PR = 1734;
48471
48511
  var COMMITS_AHEAD_OF_TAG = 0;
48472
48512
 
48473
48513
  // gateway/boot-version.ts
@@ -55370,15 +55410,20 @@ async function handleVaultRequestSaveCallback(ctx, data) {
55370
55410
  }
55371
55411
  if (action === "save") {
55372
55412
  await ctx.answerCallbackQuery({ text: "\u23F3 Saving\u2026" }).catch(() => {});
55373
- const cached = vaultPassphraseCache.get(pending2.chat_id);
55374
- if (!cached || cached.expiresAt <= Date.now()) {
55375
- if (pending2.card_message_id != null) {
55376
- await ctx.api.editMessageText(pending2.chat_id, pending2.card_message_id, `\uD83D\uDD12 <b>Vault is locked.</b> Run <code>/vault unlock</code> (or any /vault command) to cache the passphrase, then tap Save again on the next card.`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
55413
+ let write;
55414
+ if (VAULT_APPROVAL_AUTH_MODE === "telegram-id") {
55415
+ write = await defaultVaultWritePosture(pending2.key, pending2.value);
55416
+ } else {
55417
+ const cached = vaultPassphraseCache.get(pending2.chat_id);
55418
+ if (!cached || cached.expiresAt <= Date.now()) {
55419
+ if (pending2.card_message_id != null) {
55420
+ 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(() => {});
55421
+ }
55422
+ pendingVaultRequestSaves.delete(stageId);
55423
+ return;
55377
55424
  }
55378
- pendingVaultRequestSaves.delete(stageId);
55379
- return;
55425
+ write = defaultVaultWrite(pending2.key, pending2.value, cached.passphrase);
55380
55426
  }
55381
- const write = defaultVaultWrite(pending2.key, pending2.value, cached.passphrase);
55382
55427
  if (!write.ok) {
55383
55428
  const parsed = parseVaultCliError(write.output);
55384
55429
  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'
@@ -11774,47 +11774,50 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
11774
11774
  // stale "spinning" state on the button while we run the write.
11775
11775
  await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
11776
11776
 
11777
- // #1115 follow-up: telegram-id mode silent-save was withdrawn. The
11778
- // original PR shortcut routed the save through an in-memory
11779
- // passphrase the gateway held under telegram-id; the reviewer
11780
- // flagged that as a bypass surface (claude in the same container
11781
- // could exfiltrate the passphrase). The access-approve flow now
11782
- // attests via the broker (`attest_via_posture: true` on
11783
- // mint_grant) so the passphrase never leaves the broker. The
11784
- // save-approve flow uses `defaultVaultWrite` which shells to the
11785
- // CLI routing it through broker-IPC attest_via_posture is a
11786
- // tracked follow-up. Until then, vault_request_save under
11787
- // telegram-id falls through to the standard passphrase-cache
11788
- // path: the operator must have unlocked the vault in this chat
11789
- // (`/vault unlock` or any /vault command) so the cache is
11790
- // populated. Same UX as passphrase mode.
11791
-
11792
- // Fetch the cached passphrase for this chat. If the gateway hasn't
11793
- // seen the user unlock the vault yet, we can't attest the write —
11794
- // surface the unlock card via the same path the deferred-secret
11795
- // flow uses (issue #44).
11796
- const cached = vaultPassphraseCache.get(pending.chat_id)
11797
- if (!cached || cached.expiresAt <= Date.now()) {
11798
- if (pending.card_message_id != null) {
11799
- await ctx.api
11800
- .editMessageText(
11801
- pending.chat_id,
11802
- pending.card_message_id,
11803
- `🔒 <b>Vault is locked.</b> Run <code>/vault unlock</code> (or any /vault command) to cache the passphrase, then tap Save again on the next card.`,
11804
- { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
11805
- )
11806
- .catch(() => {})
11777
+ // #1115 follow-up: the save-approve flow now mirrors the access-
11778
+ // approve flow under telegram-id mode broker `put` accepts
11779
+ // `attest_via_posture: true` (server.ts:1448-1500), so the
11780
+ // gateway can attest the write without a cached passphrase.
11781
+ // Closes the UX gap where tapping Save surfaced a misleading
11782
+ // "🔒 Vault is locked" message even when the broker had been
11783
+ // auto-unlocked at boot.
11784
+ //
11785
+ // Branch: under telegram-id mode use the posture-attested put;
11786
+ // under passphrase mode keep the existing cached-passphrase +
11787
+ // shell-to-CLI path (operator must `/vault unlock` once per
11788
+ // chat session to populate `vaultPassphraseCache`).
11789
+ let write: { ok: boolean; output: string }
11790
+ if (VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
11791
+ // Posture-attested broker put. No passphrase needed. The broker
11792
+ // verifies (a) telegram-id mode, (b) per-agent peer, (c) broker
11793
+ // unlocked see server.ts:1448-1500.
11794
+ write = await defaultVaultWritePosture(pending.key, pending.value)
11795
+ } else {
11796
+ // Passphrase mode — fetch the cached passphrase for this chat.
11797
+ // If the gateway hasn't seen the user unlock the vault yet, we
11798
+ // can't attest the write — surface the unlock prompt.
11799
+ const cached = vaultPassphraseCache.get(pending.chat_id)
11800
+ if (!cached || cached.expiresAt <= Date.now()) {
11801
+ if (pending.card_message_id != null) {
11802
+ await ctx.api
11803
+ .editMessageText(
11804
+ pending.chat_id,
11805
+ pending.card_message_id,
11806
+ `🔒 <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.`,
11807
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
11808
+ )
11809
+ .catch(() => {})
11810
+ }
11811
+ pendingVaultRequestSaves.delete(stageId)
11812
+ return
11807
11813
  }
11808
- pendingVaultRequestSaves.delete(stageId)
11809
- return
11814
+ // defaultVaultWrite spawns `switchroom vault set <key>` with the
11815
+ // passphrase env set; the CLI forwards the passphrase to the
11816
+ // broker put as operator-attestation (#969 P1a), which authorizes
11817
+ // new-key creation.
11818
+ write = defaultVaultWrite(pending.key, pending.value, cached.passphrase)
11810
11819
  }
11811
11820
 
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
11821
  if (!write.ok) {
11819
11822
  // Route through the structured-error renderer from #969 P0b so
11820
11823
  // 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
- * Reuses the existing `runVaultCli`-style approach (spawn `switchroom vault
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
- * Exposed as a pure function for testability: callers inject the spawn
10
- * helper in tests to avoid needing a real vault on disk.
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) => {
@@ -121,20 +121,34 @@ describe('performVaultAccessApproval — broker-mediated attestation', () => {
121
121
  })
122
122
  })
123
123
 
124
- describe('handleVaultRequestSaveCallback — telegram-id silent path withdrawn', () => {
125
- it('NO LONGER reads an in-memory passphrase for telegram-id; falls through to cached-passphrase path', () => {
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: the original PR added a
131
- // `VAULT_APPROVAL_AUTH_MODE === 'telegram-id'` shortcut that
132
- // pulled an in-memory passphrase. That was a bypass surface.
133
- // The save handler must NOT branch on the posture for an
134
- // in-memory passphrase any more.
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
- // Standard cache lookup still present.
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
+ })