switchroom 0.18.3 → 0.18.7
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 +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source-text wiring pins for the agent-initiated approval-card durability fix.
|
|
3
|
+
*
|
|
4
|
+
* gateway.ts has top-level side effects and isn't unit-importable; the pure
|
|
5
|
+
* primitives are unit-tested in pending-card-store.test.ts and
|
|
6
|
+
* approval-timeout-inbound-builders.test.ts. These pins lock the wiring that
|
|
7
|
+
* connects them so the two failure modes can't silently regress:
|
|
8
|
+
*
|
|
9
|
+
* Defect A — pending card state was in-memory only; a gateway restart lost it
|
|
10
|
+
* and a later tap hit the "Card expired" tombstone (agent parked forever).
|
|
11
|
+
* Defect B — no wake on TTL expiry; an unanswered card left the agent parked
|
|
12
|
+
* forever (the sweeps were lazy and never woke the agent).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from 'vitest'
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
import { dirname, resolve } from 'node:path'
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
21
|
+
const read = (p: string) => readFileSync(resolve(__dirname, '..', p), 'utf8')
|
|
22
|
+
const GATEWAY = read('gateway/gateway.ts')
|
|
23
|
+
|
|
24
|
+
function slice(src: string, header: string, span = 3000): string {
|
|
25
|
+
const start = src.indexOf(header)
|
|
26
|
+
expect(start, `expected to find ${header}`).toBeGreaterThan(-1)
|
|
27
|
+
return src.slice(start, start + span)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Store instantiation ──────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe('pending-card-store wiring', () => {
|
|
33
|
+
it('imports and instantiates the durable pending-card store', () => {
|
|
34
|
+
expect(GATEWAY).toMatch(/from '\.\/pending-card-store\.js'/)
|
|
35
|
+
expect(GATEWAY).toMatch(/const pendingCardStore = createPendingCardStore\(STATE_DIR\)/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('imports the four timeout inbound builders', () => {
|
|
39
|
+
expect(GATEWAY).toMatch(/from '\.\/approval-timeout-inbound-builders\.js'/)
|
|
40
|
+
for (const b of [
|
|
41
|
+
'buildVaultAccessTimeoutInbound',
|
|
42
|
+
'buildVaultSaveTimeoutInbound',
|
|
43
|
+
'buildSecretRequestTimeoutInbound',
|
|
44
|
+
'buildMentalModelProposeTimeoutInbound',
|
|
45
|
+
]) {
|
|
46
|
+
expect(GATEWAY).toContain(b)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// ─── Defect A part 1: persistence at staging ──────────────────────────────
|
|
52
|
+
|
|
53
|
+
describe('staging persists card metadata (Defect A)', () => {
|
|
54
|
+
it('vault_request_access stages into the durable store', () => {
|
|
55
|
+
const fn = slice(GATEWAY, 'async function executeVaultRequestAccess', 6000)
|
|
56
|
+
expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'vault_request_access'/)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('request_secret stages into the durable store', () => {
|
|
60
|
+
const fn = slice(GATEWAY, 'async function executeRequestSecret', 3000)
|
|
61
|
+
expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'request_secret'/)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('mental_model_propose stages into the durable store', () => {
|
|
65
|
+
const fn = slice(GATEWAY, 'async function executeMentalModelPropose', 6000)
|
|
66
|
+
expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'mental_model_propose'/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('vault_request_save stages metadata but NEVER the secret value (hygiene)', () => {
|
|
70
|
+
const fn = slice(GATEWAY, 'async function executeVaultRequestSave', 6000)
|
|
71
|
+
const addBlock = slice(fn, "pendingCardStore.add({", 700)
|
|
72
|
+
expect(addBlock).toMatch(/family: 'vault_request_save'/)
|
|
73
|
+
// The staged secret `value` must NOT be copied into the persisted record.
|
|
74
|
+
expect(addBlock).not.toMatch(/value:/)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// ─── Defect A part 2: boot restore ────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe('boot restore (Defect A)', () => {
|
|
81
|
+
it('defines restorePendingApprovalCards and calls it at boot', () => {
|
|
82
|
+
expect(GATEWAY).toMatch(/function restorePendingApprovalCards\(\)/)
|
|
83
|
+
expect(GATEWAY).toMatch(/restorePendingApprovalCards\(\)/g)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('restored vault_request_save has no value + is flagged restoredWithoutValue', () => {
|
|
87
|
+
const fn = slice(GATEWAY, 'function restorePendingApprovalCards', 4000)
|
|
88
|
+
expect(fn).toMatch(/value: '',/)
|
|
89
|
+
expect(fn).toMatch(/restoredWithoutValue: true/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('a Save tap on a restored (valueless) card degrades gracefully instead of writing empty', () => {
|
|
93
|
+
const fn = slice(GATEWAY, 'async function handleVaultRequestSaveCallback', 9000)
|
|
94
|
+
expect(fn).toMatch(/pending\.restoredWithoutValue \|\| pending\.value\.length === 0/)
|
|
95
|
+
expect(fn).toMatch(/lost to a gateway restart/)
|
|
96
|
+
expect(fn).toMatch(/buildVaultSaveFailedInbound/)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// ─── Defect A part 3: removal on resolution ───────────────────────────────
|
|
101
|
+
|
|
102
|
+
describe('resolution clears the durable store (Defect A)', () => {
|
|
103
|
+
it('vault access approve/deny remove from the store', () => {
|
|
104
|
+
// deny path
|
|
105
|
+
const deny = slice(GATEWAY, 'async function handleVaultRequestAccessCallback', 4000)
|
|
106
|
+
expect(deny).toMatch(/pendingCardStore\.remove\(stageId\)/)
|
|
107
|
+
// approve path (performVaultAccessApproval) removes on every terminal branch
|
|
108
|
+
const approve = slice(GATEWAY, 'async function performVaultAccessApproval', 9000)
|
|
109
|
+
expect(approve).toMatch(/pendingCardStore\.remove\(stageId\)/)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('vault save resolution paths remove from the store', () => {
|
|
113
|
+
const fn = slice(GATEWAY, 'async function handleVaultRequestSaveCallback', 12000)
|
|
114
|
+
// discard / write-fail / success / passphrase-missing all clear the store.
|
|
115
|
+
const count = (fn.match(/pendingCardStore\.remove\(stageId\)/g) ?? []).length
|
|
116
|
+
expect(count).toBeGreaterThanOrEqual(3)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('secret request provide/decline remove from the store', () => {
|
|
120
|
+
// capture (provide → value arrives) removes via armed.stageId
|
|
121
|
+
expect(GATEWAY).toMatch(/pendingCardStore\.remove\(armed\.stageId\)/)
|
|
122
|
+
const decline = slice(GATEWAY, 'async function handleSecretRequestCallback', 3000)
|
|
123
|
+
expect(decline).toMatch(/pendingCardStore\.remove\(stageId\)/)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('mental model resolve removes from the store', () => {
|
|
127
|
+
const fn = slice(GATEWAY, 'async function handleMentalModelProposeCallback', 4000)
|
|
128
|
+
expect(fn).toMatch(/pendingCardStore\.remove\(stageId\)/)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// ─── Defect B: TTL expiry wakes the parked agent ──────────────────────────
|
|
133
|
+
|
|
134
|
+
describe('TTL expiry wakes the parked agent (Defect B)', () => {
|
|
135
|
+
it('the periodic reaper runs the approval-card expiry sweep inside try/catch', () => {
|
|
136
|
+
const reaper = slice(GATEWAY, 'const pendingStateReaper = setInterval', 12000)
|
|
137
|
+
// The sweep call must be guarded like the sibling sweepStaleTurnActiveMarker:
|
|
138
|
+
// an escaped throw inside this setInterval callback reaches uncaughtException
|
|
139
|
+
// and takes the whole gateway down.
|
|
140
|
+
expect(reaper).toMatch(/try\s*\{\s*sweepExpiredApprovalCards\(now\)\s*\}\s*catch/)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('sweepExpiredApprovalCards runs all four family sweeps', () => {
|
|
144
|
+
const fn = slice(GATEWAY, 'function sweepExpiredApprovalCards', 600)
|
|
145
|
+
expect(fn).toMatch(/sweepPendingVaultRequestAccesses\(now\)/)
|
|
146
|
+
expect(fn).toMatch(/sweepPendingVaultRequestSaves\(now\)/)
|
|
147
|
+
expect(fn).toMatch(/sweepPendingMentalModelProposes\(now\)/)
|
|
148
|
+
expect(fn).toMatch(/sweepSecretRequests\(now\)/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('each expire* helper routes through the guarded expirePendingCard core', () => {
|
|
152
|
+
for (const [fnName, builder] of [
|
|
153
|
+
['function expireVaultAccessCard', 'buildVaultAccessTimeoutInbound'],
|
|
154
|
+
['function expireVaultSaveCard', 'buildVaultSaveTimeoutInbound'],
|
|
155
|
+
['function expireSecretRequestCard', 'buildSecretRequestTimeoutInbound'],
|
|
156
|
+
['function expireMentalModelProposeCard', 'buildMentalModelProposeTimeoutInbound'],
|
|
157
|
+
] as const) {
|
|
158
|
+
const fn = slice(GATEWAY, fnName, 2400)
|
|
159
|
+
// The pure core owns ordering + fault isolation (delete-before-deliver,
|
|
160
|
+
// recordMiss-before-deliver, guarded deliver) — pinned BEHAVIORALLY in
|
|
161
|
+
// pending-card-expiry.test.ts. Here we pin that the gateway routes each
|
|
162
|
+
// family through it with the right dependencies.
|
|
163
|
+
expect(fn, fnName).toMatch(/expirePendingCard\(\{/)
|
|
164
|
+
expect(fn, fnName).toMatch(new RegExp(builder))
|
|
165
|
+
// turn-safe injection gate
|
|
166
|
+
expect(fn, fnName).toMatch(/deliverResumeSyntheticOrBuffer\(/)
|
|
167
|
+
// re-offered when the operator returns
|
|
168
|
+
expect(fn, fnName).toMatch(/recordMissedApproval\(/)
|
|
169
|
+
// card edited to expired + store cleared
|
|
170
|
+
expect(fn, fnName).toMatch(/editCardExpired\(/)
|
|
171
|
+
expect(fn, fnName).toMatch(/pendingCardStore\.remove\(stageId\)/)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('each family sweep is per-entry guarded via sweepExpiredEntries', () => {
|
|
176
|
+
for (const fnName of [
|
|
177
|
+
'function sweepPendingVaultRequestAccesses',
|
|
178
|
+
'function sweepPendingVaultRequestSaves',
|
|
179
|
+
'function sweepPendingMentalModelProposes',
|
|
180
|
+
'function sweepSecretRequests',
|
|
181
|
+
]) {
|
|
182
|
+
const fn = slice(GATEWAY, fnName, 500)
|
|
183
|
+
expect(fn, fnName).toMatch(/sweepExpiredEntries\(/)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('recordMissedApproval writes to the missed-approvals store (re-offer path)', () => {
|
|
188
|
+
const fn = slice(GATEWAY, 'function recordMissedApproval', 700)
|
|
189
|
+
expect(fn).toMatch(/missedApprovalsStore\.add\(/)
|
|
190
|
+
expect(fn).toMatch(/MISSED_APPROVAL_REOFFER_ENABLED/)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// ─── Copy-bug fix: derive the timeout window from the real TTL ─────────────
|
|
195
|
+
|
|
196
|
+
describe('vault_request_access copy derives the window from the real TTL', () => {
|
|
197
|
+
it('no hard-coded "10 min" claim; uses VAULT_REQUEST_ACCESS_TTL_MS', () => {
|
|
198
|
+
const fn = slice(GATEWAY, 'async function executeVaultRequestAccess', 12000)
|
|
199
|
+
expect(fn).not.toMatch(/times out \(10 min\)/)
|
|
200
|
+
expect(fn).toMatch(/Math\.round\(VAULT_REQUEST_ACCESS_TTL_MS \/ 60000\)/)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavioral pins for the pure approval-card expiry core
|
|
3
|
+
* (gateway/pending-card-expiry.ts) — the ordering + fault-isolation contract
|
|
4
|
+
* the gateway's reaper relies on:
|
|
5
|
+
*
|
|
6
|
+
* - single-shot: two reaper ticks fire exactly ONE synthetic per expired card
|
|
7
|
+
* (delete-before-deliver, pinned behaviorally, not by regex),
|
|
8
|
+
* - a throwing deliver (half-dead IPC socket) never loses the
|
|
9
|
+
* missed-approvals re-offer and never escapes to the caller,
|
|
10
|
+
* - one throwing expiry never skips the remaining entries in a sweep.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
14
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
15
|
+
import {
|
|
16
|
+
expirePendingCard,
|
|
17
|
+
sweepExpiredEntries,
|
|
18
|
+
type ExpireCardDeps,
|
|
19
|
+
} from '../gateway/pending-card-expiry.js'
|
|
20
|
+
|
|
21
|
+
const INBOUND: InboundMessage = {
|
|
22
|
+
type: 'inbound',
|
|
23
|
+
chatId: '1',
|
|
24
|
+
messageId: 1,
|
|
25
|
+
user: 'vault-broker',
|
|
26
|
+
userId: 0,
|
|
27
|
+
ts: 1,
|
|
28
|
+
text: 'timeout',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeDeps(overrides: Partial<ExpireCardDeps> = {}): ExpireCardDeps & {
|
|
32
|
+
calls: string[]
|
|
33
|
+
} {
|
|
34
|
+
const calls: string[] = []
|
|
35
|
+
return {
|
|
36
|
+
calls,
|
|
37
|
+
remove: () => calls.push('remove'),
|
|
38
|
+
editCard: () => calls.push('editCard'),
|
|
39
|
+
buildInbound: () => {
|
|
40
|
+
calls.push('buildInbound')
|
|
41
|
+
return INBOUND
|
|
42
|
+
},
|
|
43
|
+
deliver: () => {
|
|
44
|
+
calls.push('deliver')
|
|
45
|
+
return true
|
|
46
|
+
},
|
|
47
|
+
recordMiss: () => calls.push('recordMiss'),
|
|
48
|
+
log: () => {},
|
|
49
|
+
...overrides,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('expirePendingCard — ordering contract', () => {
|
|
54
|
+
it('runs remove FIRST, then editCard, then recordMiss BEFORE deliver', () => {
|
|
55
|
+
const deps = makeDeps()
|
|
56
|
+
const { delivered } = expirePendingCard(deps)
|
|
57
|
+
expect(delivered).toBe(true)
|
|
58
|
+
expect(deps.calls).toEqual(['remove', 'editCard', 'recordMiss', 'buildInbound', 'deliver'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('a throwing deliver returns delivered=false, never throws, and the re-offer is already recorded', () => {
|
|
62
|
+
const log = vi.fn()
|
|
63
|
+
const deps = makeDeps({
|
|
64
|
+
deliver: () => {
|
|
65
|
+
throw new Error('EPIPE: half-dead socket')
|
|
66
|
+
},
|
|
67
|
+
log,
|
|
68
|
+
})
|
|
69
|
+
const { delivered } = expirePendingCard(deps)
|
|
70
|
+
expect(delivered).toBe(false)
|
|
71
|
+
// recordMiss ran BEFORE the deliver attempt — the missed-approvals entry
|
|
72
|
+
// survives the failed wake (the operator's return re-offers it).
|
|
73
|
+
expect(deps.calls).toContain('recordMiss')
|
|
74
|
+
expect(deps.calls.indexOf('recordMiss')).toBeLessThan(deps.calls.indexOf('buildInbound'))
|
|
75
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining('delivery failed'))
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('a throwing editCard still records the miss and delivers the wake', () => {
|
|
79
|
+
const deps = makeDeps({
|
|
80
|
+
editCard: () => {
|
|
81
|
+
throw new Error('telegram edit failed')
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
const { delivered } = expirePendingCard(deps)
|
|
85
|
+
expect(delivered).toBe(true)
|
|
86
|
+
expect(deps.calls).toEqual(['remove', 'recordMiss', 'buildInbound', 'deliver'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('a throwing recordMiss still delivers the wake', () => {
|
|
90
|
+
const deps = makeDeps({
|
|
91
|
+
recordMiss: () => {
|
|
92
|
+
throw new Error('store write failed')
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
const { delivered } = expirePendingCard(deps)
|
|
96
|
+
expect(delivered).toBe(true)
|
|
97
|
+
expect(deps.calls).toContain('deliver')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('sweepExpiredEntries — single-shot across ticks', () => {
|
|
102
|
+
interface Entry {
|
|
103
|
+
staged_at: number
|
|
104
|
+
}
|
|
105
|
+
const TTL = 60_000
|
|
106
|
+
|
|
107
|
+
function makeSweepWorld() {
|
|
108
|
+
const map = new Map<string, Entry>()
|
|
109
|
+
const synthetics: string[] = []
|
|
110
|
+
const expire = (stageId: string, _v: Entry, _now: number) => {
|
|
111
|
+
// Mirrors the gateway wiring: expirePendingCard with remove-from-map
|
|
112
|
+
// FIRST and the synthetic recorded on deliver.
|
|
113
|
+
expirePendingCard({
|
|
114
|
+
remove: () => map.delete(stageId),
|
|
115
|
+
editCard: () => {},
|
|
116
|
+
buildInbound: () => INBOUND,
|
|
117
|
+
deliver: () => {
|
|
118
|
+
synthetics.push(stageId)
|
|
119
|
+
return true
|
|
120
|
+
},
|
|
121
|
+
recordMiss: () => {},
|
|
122
|
+
log: () => {},
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
const tick = (now: number) =>
|
|
126
|
+
sweepExpiredEntries(map, (v, n) => v.staged_at < n - TTL, expire, now, () => {})
|
|
127
|
+
return { map, synthetics, tick }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
it('two reaper ticks fire exactly ONE synthetic per expired card', () => {
|
|
131
|
+
const { map, synthetics, tick } = makeSweepWorld()
|
|
132
|
+
map.set('s1', { staged_at: 0 })
|
|
133
|
+
map.set('s2', { staged_at: 0 })
|
|
134
|
+
map.set('fresh', { staged_at: 150_000 }) // still inside TTL at both ticks
|
|
135
|
+
|
|
136
|
+
tick(120_000) // both s1+s2 expired; fresh is not
|
|
137
|
+
tick(180_000) // second tick — expired entries are GONE from the map
|
|
138
|
+
|
|
139
|
+
expect(synthetics.sort()).toEqual(['s1', 's2'])
|
|
140
|
+
expect(map.has('fresh')).toBe(true)
|
|
141
|
+
expect(map.size).toBe(1)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('delete-before-deliver: the map entry is gone by the time the synthetic fires', () => {
|
|
145
|
+
const map = new Map<string, Entry>()
|
|
146
|
+
map.set('s1', { staged_at: 0 })
|
|
147
|
+
let presentAtDeliver: boolean | null = null
|
|
148
|
+
sweepExpiredEntries(
|
|
149
|
+
map,
|
|
150
|
+
(v, n) => v.staged_at < n - TTL,
|
|
151
|
+
(stageId) => {
|
|
152
|
+
expirePendingCard({
|
|
153
|
+
remove: () => map.delete(stageId),
|
|
154
|
+
editCard: () => {},
|
|
155
|
+
buildInbound: () => INBOUND,
|
|
156
|
+
deliver: () => {
|
|
157
|
+
presentAtDeliver = map.has(stageId)
|
|
158
|
+
return true
|
|
159
|
+
},
|
|
160
|
+
recordMiss: () => {},
|
|
161
|
+
log: () => {},
|
|
162
|
+
})
|
|
163
|
+
},
|
|
164
|
+
120_000,
|
|
165
|
+
() => {},
|
|
166
|
+
)
|
|
167
|
+
expect(presentAtDeliver).toBe(false)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('one throwing expiry does not skip the remaining entries', () => {
|
|
171
|
+
const map = new Map<string, Entry>()
|
|
172
|
+
map.set('boom', { staged_at: 0 })
|
|
173
|
+
map.set('ok', { staged_at: 0 })
|
|
174
|
+
const expired: string[] = []
|
|
175
|
+
const log = vi.fn()
|
|
176
|
+
sweepExpiredEntries(
|
|
177
|
+
map,
|
|
178
|
+
(v, n) => v.staged_at < n - TTL,
|
|
179
|
+
(stageId) => {
|
|
180
|
+
if (stageId === 'boom') throw new Error('dead socket at the worst layer')
|
|
181
|
+
map.delete(stageId)
|
|
182
|
+
expired.push(stageId)
|
|
183
|
+
},
|
|
184
|
+
120_000,
|
|
185
|
+
log,
|
|
186
|
+
)
|
|
187
|
+
expect(expired).toEqual(['ok'])
|
|
188
|
+
expect(log).toHaveBeenCalledWith(expect.stringContaining('boom'))
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable store for the four AGENT-INITIATED approval-card families
|
|
3
|
+
* (vault_request_access / vault_request_save / request_secret /
|
|
4
|
+
* mental_model_propose). Mirrors permission-card-store.test.ts.
|
|
5
|
+
*
|
|
6
|
+
* The load-bearing extra invariant here (beyond plain persistence): NO SECRET
|
|
7
|
+
* VALUE ever lands on disk. The file is written unencrypted; a vault_request_save
|
|
8
|
+
* stages the secret value in gateway memory only, and this store must persist
|
|
9
|
+
* just its key/metadata. The "no value field" test is the regression guard for
|
|
10
|
+
* that hygiene rule.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
14
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, statSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import {
|
|
18
|
+
createPendingCardStore,
|
|
19
|
+
type PersistedApprovalCard,
|
|
20
|
+
} from '../gateway/pending-card-store.js'
|
|
21
|
+
|
|
22
|
+
function makeTmpDir() {
|
|
23
|
+
return mkdtempSync(join(tmpdir(), 'pending-card-store-test-'))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ACCESS: PersistedApprovalCard = {
|
|
27
|
+
family: 'vault_request_access',
|
|
28
|
+
stageId: 'a1b2c3d4',
|
|
29
|
+
agent: 'gymbro',
|
|
30
|
+
chatId: '10001',
|
|
31
|
+
cardMessageId: 555,
|
|
32
|
+
key: 'fatsecret/credentials',
|
|
33
|
+
scope: 'read',
|
|
34
|
+
reason: 'needs the API for the daily log',
|
|
35
|
+
ttlSeconds: 30 * 86400,
|
|
36
|
+
stagedAt: 1_700_000_000_000,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SAVE: PersistedApprovalCard = {
|
|
40
|
+
family: 'vault_request_save',
|
|
41
|
+
stageId: 'e5f6a7b8',
|
|
42
|
+
agent: 'marko',
|
|
43
|
+
chatId: '10002',
|
|
44
|
+
cardMessageId: 556,
|
|
45
|
+
key: 'brevo/api-key',
|
|
46
|
+
kind: 'string',
|
|
47
|
+
why: 'store the campaign key',
|
|
48
|
+
stagedAt: 1_700_000_000_000,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SECRET: PersistedApprovalCard = {
|
|
52
|
+
family: 'request_secret',
|
|
53
|
+
stageId: 'c9d0e1f2',
|
|
54
|
+
agent: 'gymbro',
|
|
55
|
+
chatId: '10003',
|
|
56
|
+
cardMessageId: 557,
|
|
57
|
+
key: 'openai/token',
|
|
58
|
+
reason: 'the model call needs it',
|
|
59
|
+
stagedAt: 1_700_000_000_000,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const MMP: PersistedApprovalCard = {
|
|
63
|
+
family: 'mental_model_propose',
|
|
64
|
+
stageId: 'aabbccdd',
|
|
65
|
+
agent: 'gymbro',
|
|
66
|
+
chatId: '10004',
|
|
67
|
+
cardMessageId: 558,
|
|
68
|
+
spec: { name: 'training-plan-state', source_query: 'the user current training plan' },
|
|
69
|
+
stagedAt: 1_700_000_000_000,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('createPendingCardStore', () => {
|
|
73
|
+
let dir: string
|
|
74
|
+
beforeEach(() => { dir = makeTmpDir() })
|
|
75
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
|
|
76
|
+
|
|
77
|
+
it('starts empty', () => {
|
|
78
|
+
expect(createPendingCardStore(dir).loadAll()).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('add + loadAll round-trips each family', () => {
|
|
82
|
+
const store = createPendingCardStore(dir)
|
|
83
|
+
store.add(ACCESS)
|
|
84
|
+
store.add(SAVE)
|
|
85
|
+
store.add(SECRET)
|
|
86
|
+
store.add(MMP)
|
|
87
|
+
const all = store.loadAll()
|
|
88
|
+
expect(all.length).toBe(4)
|
|
89
|
+
expect(new Set(all.map(e => e.family))).toEqual(
|
|
90
|
+
new Set(['vault_request_access', 'vault_request_save', 'request_secret', 'mental_model_propose']),
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('add is idempotent on stageId (replaces in place)', () => {
|
|
95
|
+
const store = createPendingCardStore(dir)
|
|
96
|
+
store.add(SAVE)
|
|
97
|
+
store.add({ ...SAVE, cardMessageId: 999 })
|
|
98
|
+
const all = store.loadAll()
|
|
99
|
+
expect(all.length).toBe(1)
|
|
100
|
+
expect((all[0] as { cardMessageId?: number }).cardMessageId).toBe(999)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('remove drops only the matching stageId', () => {
|
|
104
|
+
const store = createPendingCardStore(dir)
|
|
105
|
+
store.add(ACCESS)
|
|
106
|
+
store.add(SAVE)
|
|
107
|
+
store.remove(ACCESS.stageId)
|
|
108
|
+
const all = store.loadAll()
|
|
109
|
+
expect(all.length).toBe(1)
|
|
110
|
+
expect(all[0].stageId).toBe(SAVE.stageId)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('remove is idempotent when the entry is absent', () => {
|
|
114
|
+
const store = createPendingCardStore(dir)
|
|
115
|
+
expect(() => store.remove('nope')).not.toThrow()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('clear empties the store', () => {
|
|
119
|
+
const store = createPendingCardStore(dir)
|
|
120
|
+
store.add(ACCESS)
|
|
121
|
+
store.clear()
|
|
122
|
+
expect(store.loadAll()).toEqual([])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('survives re-instantiation (data persists across gateway restart)', () => {
|
|
126
|
+
createPendingCardStore(dir).add(ACCESS)
|
|
127
|
+
const all = createPendingCardStore(dir).loadAll()
|
|
128
|
+
expect(all.length).toBe(1)
|
|
129
|
+
expect(all[0].stageId).toBe(ACCESS.stageId)
|
|
130
|
+
// The restored access card carries everything a post-restart tap needs to
|
|
131
|
+
// mint the grant: agent, key, scope, ttl, chat, card message id.
|
|
132
|
+
const restored = all[0] as Extract<PersistedApprovalCard, { family: 'vault_request_access' }>
|
|
133
|
+
expect(restored.agent).toBe('gymbro')
|
|
134
|
+
expect(restored.key).toBe('fatsecret/credentials')
|
|
135
|
+
expect(restored.scope).toBe('read')
|
|
136
|
+
expect(restored.ttlSeconds).toBe(30 * 86400)
|
|
137
|
+
expect(restored.cardMessageId).toBe(555)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// ── Secrets hygiene — the whole reason this store persists metadata only ──
|
|
141
|
+
it('a vault_request_save record has NO `value` field (type + runtime)', () => {
|
|
142
|
+
// Type-level: PersistedVaultSaveCard has no `value` member, so this object
|
|
143
|
+
// literal wouldn't compile with one. Runtime-level: assert it's absent.
|
|
144
|
+
expect('value' in SAVE).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('the on-disk JSON contains NO secret value for a saved card', () => {
|
|
148
|
+
const store = createPendingCardStore(dir)
|
|
149
|
+
// Simulate the gateway callsite: it builds the persisted record from the
|
|
150
|
+
// pending entry WITHOUT copying `value`. Even if a caller mistakenly spread
|
|
151
|
+
// a value in, the type wouldn't allow it — but assert the file is clean.
|
|
152
|
+
store.add(SAVE)
|
|
153
|
+
const raw = readFileSync(join(dir, 'pending-approval-cards.json'), 'utf-8')
|
|
154
|
+
const SECRET_SENTINEL = 'super-secret-brevo-value'
|
|
155
|
+
expect(raw).not.toContain(SECRET_SENTINEL)
|
|
156
|
+
expect(raw).not.toContain('"value"')
|
|
157
|
+
// The key/metadata we DO need is present.
|
|
158
|
+
expect(raw).toContain('brevo/api-key')
|
|
159
|
+
expect(raw).toContain('vault_request_save')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('the file ends up 0600 even when it pre-existed with lax perms', () => {
|
|
163
|
+
// writeFileSync's `mode` only applies on CREATE — a pre-existing
|
|
164
|
+
// world-readable file would keep its perms without the explicit chmod
|
|
165
|
+
// the store does on every write.
|
|
166
|
+
const filePath = join(dir, 'pending-approval-cards.json')
|
|
167
|
+
writeFileSync(filePath, '[]', { mode: 0o644 })
|
|
168
|
+
const store = createPendingCardStore(dir)
|
|
169
|
+
store.add(ACCESS)
|
|
170
|
+
expect(statSync(filePath).mode & 0o777).toBe(0o600)
|
|
171
|
+
expect(createPendingCardStore(dir).loadAll().length).toBe(1)
|
|
172
|
+
})
|
|
173
|
+
})
|