@openparachute/agent 0.1.1 → 0.1.2

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +1 -1
  3. package/package.json +1 -1
  4. package/scripts/init-cli-agent.ts +2 -1
  5. package/scripts/init-first-agent.ts +2 -1
  6. package/scripts/seed-discord.ts +2 -1
  7. package/src/channels/api-translator.test.ts +306 -0
  8. package/src/channels/api-translator.ts +214 -0
  9. package/src/config.ts +23 -3
  10. package/src/container-runtime.test.ts +101 -1
  11. package/src/container-runtime.ts +76 -1
  12. package/src/db/connection.migrate.test.ts +35 -2
  13. package/src/db/connection.ts +40 -5
  14. package/src/index.ts +6 -1
  15. package/src/mcp/tools/channels.test.ts +126 -0
  16. package/src/mcp/tools/channels.ts +33 -98
  17. package/src/modules/mount-security/expand-path.test.ts +82 -0
  18. package/src/modules/mount-security/index.ts +21 -10
  19. package/src/modules/permissions/sender-approval.test.ts +171 -0
  20. package/src/secrets/index.ts +127 -21
  21. package/src/secrets/secrets.test.ts +301 -4
  22. package/src/session-manager.attachments.test.ts +171 -0
  23. package/src/session-manager.dup-skip.test.ts +173 -0
  24. package/src/session-manager.ts +22 -4
  25. package/src/types.ts +4 -1
  26. package/src/web/routes/channels-mga-detail.test.ts +49 -2
  27. package/src/web/routes/channels.ts +25 -203
  28. package/src/web/routes/secrets.test.ts +46 -1
  29. package/src/web/routes/secrets.ts +35 -0
  30. package/src/web/server.ts +34 -13
  31. package/src/web/services-manifest.test.ts +37 -9
  32. package/src/web/services-manifest.ts +14 -9
  33. package/web/ui/index.html +2 -2
  34. package/web/ui/src/App.tsx +1 -1
  35. package/web/ui/src/lib/api.test.ts +2 -2
  36. package/web/ui/src/lib/api.ts +40 -2
  37. package/web/ui/src/lib/auth.test.ts +214 -1
  38. package/web/ui/src/lib/auth.ts +79 -22
  39. package/web/ui/src/routes/ChannelWireDetail.test.tsx +2 -2
  40. package/web/ui/src/routes/ChannelWireDetail.tsx +1 -1
  41. package/web/ui/src/routes/GroupDetail.test.tsx +206 -0
  42. package/web/ui/src/routes/GroupDetail.tsx +126 -1
  43. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +1 -1
  44. package/web/ui/src/routes/SecretsList.tsx +22 -1
  45. package/web/ui/src/routes/VaultDetail.test.tsx +2 -0
@@ -11,6 +11,7 @@ import {
11
11
  getSecret,
12
12
  getSecretById,
13
13
  listAssignments,
14
+ listInjectableSecretsForGroup,
14
15
  listSecrets,
15
16
  putSecret,
16
17
  removeAssignment,
@@ -108,17 +109,21 @@ describe('secrets store', () => {
108
109
  expect(env.get('B')).toBe('scoped-B');
109
110
  });
110
111
 
111
- it('mode=selective injects nothing without explicit assignments', () => {
112
+ it('mode=selective hides unassigned globals, but scoped secrets are auto-seeded into the owner', () => {
112
113
  const db = initTestDb();
113
114
  runMigrations(db);
114
115
  _setMasterKeyForTest(crypto.randomBytes(32));
115
116
  seedAgentGroup(db, 'g1', 'selective');
116
117
 
117
- putSecret('GLOBAL', 'value');
118
- putSecret('SCOPED', 'value', { agent_group_id: 'g1' });
118
+ // Unassigned global → invisible under selective mode.
119
+ putSecret('GLOBAL', 'global-v');
120
+ // Scoped secret → putSecret auto-seeds the (id, g1) assignment row, so
121
+ // the resolver injects it even under selective mode (paraclaw#127).
122
+ putSecret('SCOPED', 'scoped-v', { agent_group_id: 'g1' });
119
123
 
120
124
  const env = resolveInjectableSecrets('g1');
121
- expect(env.size).toBe(0);
125
+ expect([...env.keys()]).toEqual(['SCOPED']);
126
+ expect(env.get('SCOPED')).toBe('scoped-v');
122
127
  });
123
128
 
124
129
  it('unknown agent_group_id resolves as no-secrets (selective default)', () => {
@@ -127,6 +132,112 @@ describe('secrets store', () => {
127
132
  });
128
133
  });
129
134
 
135
+ describe('putSecret auto-seeds the owner assignment for scoped creates (paraclaw#127)', () => {
136
+ /**
137
+ * The default `agent_groups.secret_mode` is `selective` (migration 023).
138
+ * Before the auto-seed, calling `putSecret(name, value, { agent_group_id })`
139
+ * inserted a `secrets` row but never the matching `secret_assignments` row,
140
+ * leaving the secret silently invisible to `resolveInjectableSecrets` —
141
+ * the row predicate `(s.agent_group_id = g.id OR s.agent_group_id IS NULL)`
142
+ * matches but the gate `(g.secret_mode='all' OR a.secret_id IS NOT NULL)`
143
+ * rejects. UI layers had a follow-up `setSecretAssignments` that papered
144
+ * over this on edits, but the create-side `CredentialForm` "free" mode
145
+ * called only `putSecret` — so the standard "+ New secret" flow produced
146
+ * orphan rows.
147
+ *
148
+ * Fix: `putSecret` writes the (id, owning_group) assignment row in the
149
+ * same transaction on INSERT. UPDATE/rotate leaves the assignment set
150
+ * alone (operator may have deliberately revoked).
151
+ */
152
+
153
+ it('scoped create writes a matching secret_assignments row', () => {
154
+ const db = initTestDb();
155
+ runMigrations(db);
156
+ _setMasterKeyForTest(crypto.randomBytes(32));
157
+ seedAgentGroup(db, 'A', 'selective');
158
+
159
+ const id = putSecret('TOKEN', 'v', { agent_group_id: 'A' });
160
+
161
+ expect(listAssignments(id)).toEqual(['A']);
162
+ });
163
+
164
+ it('scoped create in selective mode is visible via resolveInjectableSecrets without an explicit assign call', () => {
165
+ const db = initTestDb();
166
+ runMigrations(db);
167
+ _setMasterKeyForTest(crypto.randomBytes(32));
168
+ seedAgentGroup(db, 'A', 'selective');
169
+
170
+ putSecret('TOKEN', 'scoped-v', { agent_group_id: 'A' });
171
+
172
+ expect(resolveInjectableSecrets('A').get('TOKEN')).toBe('scoped-v');
173
+ });
174
+
175
+ it('scoped create in selective mode is visible via listInjectableSecretsForGroup, tagged scope=scoped', () => {
176
+ const db = initTestDb();
177
+ runMigrations(db);
178
+ _setMasterKeyForTest(crypto.randomBytes(32));
179
+ seedAgentGroup(db, 'A', 'selective');
180
+
181
+ putSecret('TOKEN', 'scoped-v', { agent_group_id: 'A' });
182
+
183
+ const rows = listInjectableSecretsForGroup('A');
184
+ expect(rows).toHaveLength(1);
185
+ expect(rows[0].name).toBe('TOKEN');
186
+ expect(rows[0].scope).toBe('scoped');
187
+ });
188
+
189
+ it('global create does NOT write any secret_assignments row', () => {
190
+ const db = initTestDb();
191
+ runMigrations(db);
192
+ _setMasterKeyForTest(crypto.randomBytes(32));
193
+
194
+ const id = putSecret('GLOBAL_TOKEN', 'v');
195
+
196
+ expect(listAssignments(id)).toEqual([]);
197
+ const total = db.prepare<{ n: number }>(`SELECT COUNT(*) AS n FROM secret_assignments`).get();
198
+ expect(total?.n).toBe(0);
199
+ });
200
+
201
+ it('rotate (UPDATE path) does NOT touch the assignment set', () => {
202
+ // Operator may have deliberately revoked the auto-seeded assignment —
203
+ // a rotate must not re-seed it. The auto-seed is INSERT-only.
204
+ const db = initTestDb();
205
+ runMigrations(db);
206
+ _setMasterKeyForTest(crypto.randomBytes(32));
207
+ seedAgentGroup(db, 'A', 'selective');
208
+
209
+ const id = putSecret('TOKEN', 'v1', { agent_group_id: 'A' });
210
+ expect(listAssignments(id)).toEqual(['A']);
211
+
212
+ // Operator revokes.
213
+ removeAssignment(id, 'A');
214
+ expect(listAssignments(id)).toEqual([]);
215
+
216
+ // Rotate plaintext — assignment set stays empty.
217
+ const id2 = putSecret('TOKEN', 'v2', { agent_group_id: 'A' });
218
+ expect(id2).toBe(id);
219
+ expect(listAssignments(id)).toEqual([]);
220
+ });
221
+
222
+ it('two scoped creates with different owners each seed their own row', () => {
223
+ const db = initTestDb();
224
+ runMigrations(db);
225
+ _setMasterKeyForTest(crypto.randomBytes(32));
226
+ seedAgentGroup(db, 'A', 'selective');
227
+ seedAgentGroup(db, 'B', 'selective');
228
+
229
+ const aId = putSecret('TOKEN', 'va', { agent_group_id: 'A' });
230
+ const bId = putSecret('TOKEN', 'vb', { agent_group_id: 'B' });
231
+
232
+ expect(aId).not.toBe(bId);
233
+ expect(listAssignments(aId)).toEqual(['A']);
234
+ expect(listAssignments(bId)).toEqual(['B']);
235
+ // Each scoped create is visible only in its own group, never across.
236
+ expect(resolveInjectableSecrets('A').get('TOKEN')).toBe('va');
237
+ expect(resolveInjectableSecrets('B').get('TOKEN')).toBe('vb');
238
+ });
239
+ });
240
+
130
241
  describe('secret assignments (selective mode)', () => {
131
242
  it('round-trips: assignment to A injects into A, not B', () => {
132
243
  const db = initTestDb();
@@ -352,3 +463,189 @@ describe('getSecretById', () => {
352
463
  expect(getSecretById('does-not-exist')).toBeUndefined();
353
464
  });
354
465
  });
466
+
467
+ describe('listInjectableSecretsForGroup', () => {
468
+ it('tags scoped, assigned, and global rows correctly', () => {
469
+ const db = initTestDb();
470
+ runMigrations(db);
471
+ _setMasterKeyForTest(crypto.randomBytes(32));
472
+ seedAgentGroup(db, 'A', 'all');
473
+
474
+ // Three inclusion paths:
475
+ // SCOPED — owned by group A
476
+ // ASSIGNED — global, with explicit assignment row → A
477
+ // GLOBAL — global, included only because A is mode='all'
478
+ const scopedId = putSecret('SCOPED_TOKEN', 'v', { agent_group_id: 'A' });
479
+ const assignedId = putSecret('ASSIGNED_TOKEN', 'v');
480
+ addAssignment(assignedId, 'A');
481
+ const globalId = putSecret('GLOBAL_TOKEN', 'v');
482
+
483
+ const rows = listInjectableSecretsForGroup('A');
484
+ const byName = new Map(rows.map((r) => [r.name, r]));
485
+
486
+ expect(byName.get('SCOPED_TOKEN')?.scope).toBe('scoped');
487
+ expect(byName.get('SCOPED_TOKEN')?.id).toBe(scopedId);
488
+ expect(byName.get('ASSIGNED_TOKEN')?.scope).toBe('assigned');
489
+ expect(byName.get('ASSIGNED_TOKEN')?.id).toBe(assignedId);
490
+ expect(byName.get('GLOBAL_TOKEN')?.scope).toBe('global');
491
+ expect(byName.get('GLOBAL_TOKEN')?.id).toBe(globalId);
492
+ });
493
+
494
+ it('selective mode hides globals that have no assignment row', () => {
495
+ const db = initTestDb();
496
+ runMigrations(db);
497
+ _setMasterKeyForTest(crypto.randomBytes(32));
498
+ seedAgentGroup(db, 'A', 'selective');
499
+
500
+ putSecret('UNREACHABLE_GLOBAL', 'v');
501
+ const assignedId = putSecret('ASSIGNED', 'v');
502
+ addAssignment(assignedId, 'A');
503
+
504
+ const rows = listInjectableSecretsForGroup('A');
505
+ expect(rows.map((r) => r.name).sort()).toEqual(['ASSIGNED']);
506
+ expect(rows[0].scope).toBe('assigned');
507
+ });
508
+
509
+ it('assignment row + mode=all → assigned wins (more specific wins)', () => {
510
+ const db = initTestDb();
511
+ runMigrations(db);
512
+ _setMasterKeyForTest(crypto.randomBytes(32));
513
+ seedAgentGroup(db, 'A', 'all');
514
+
515
+ const id = putSecret('SHARED', 'v');
516
+ addAssignment(id, 'A');
517
+
518
+ const rows = listInjectableSecretsForGroup('A');
519
+ expect(rows).toHaveLength(1);
520
+ expect(rows[0].scope).toBe('assigned');
521
+ });
522
+
523
+ it('on name collision, scoped row wins and reports scope=scoped', () => {
524
+ const db = initTestDb();
525
+ runMigrations(db);
526
+ _setMasterKeyForTest(crypto.randomBytes(32));
527
+ seedAgentGroup(db, 'A', 'all');
528
+
529
+ putSecret('TOKEN', 'global-v');
530
+ putSecret('TOKEN', 'scoped-v', { agent_group_id: 'A' });
531
+
532
+ const rows = listInjectableSecretsForGroup('A');
533
+ expect(rows).toHaveLength(1);
534
+ expect(rows[0].name).toBe('TOKEN');
535
+ expect(rows[0].scope).toBe('scoped');
536
+ });
537
+
538
+ it('returns an empty list for an unknown agent group', () => {
539
+ putSecret('GLOBAL', 'v');
540
+ expect(listInjectableSecretsForGroup('does-not-exist')).toEqual([]);
541
+ });
542
+
543
+ it('never carries the encrypted value', () => {
544
+ const db = initTestDb();
545
+ runMigrations(db);
546
+ _setMasterKeyForTest(crypto.randomBytes(32));
547
+ seedAgentGroup(db, 'A', 'all');
548
+ putSecret('K', 'super-secret');
549
+ const rows = listInjectableSecretsForGroup('A');
550
+ expect(rows[0]).not.toHaveProperty('value_encrypted');
551
+ });
552
+ });
553
+
554
+ describe('resolveInjectableSecrets ↔ listInjectableSecretsForGroup lockstep (paraclaw#129)', () => {
555
+ /**
556
+ * Mechanical guard against drift between the two SQL-identical functions in
557
+ * src/secrets/index.ts. Both walk identical row predicates + gate clauses;
558
+ * any future SQL edit must touch both. Today the invariant is preserved by
559
+ * careful reading + a load-bearing doc-comment. This block tests it.
560
+ *
561
+ * For each fixture, calls both functions and asserts:
562
+ * - same set of names (the row gate matches)
563
+ * - per-name plaintext from resolveInjectableSecrets matches the value
564
+ * getSecret returns when scoped to the same group (the dedup-by-name
565
+ * `ORDER BY s.agent_group_id IS NULL` scoped-wins ordering matches)
566
+ *
567
+ * If a future SQL change makes one function accept a row the other rejects,
568
+ * the name-set assertion fails. If the ORDER BY drifts so dedup picks the
569
+ * wrong row on collision, the per-name plaintext assertion fails.
570
+ */
571
+ function expectLockstep(agentGroupId: string, expectedNames: string[]): void {
572
+ const resolved = resolveInjectableSecrets(agentGroupId);
573
+ const listed = listInjectableSecretsForGroup(agentGroupId);
574
+
575
+ const sortedExpected = expectedNames.slice().sort();
576
+ expect([...resolved.keys()].sort()).toEqual(sortedExpected);
577
+ expect(listed.map((r) => r.name).sort()).toEqual(sortedExpected);
578
+
579
+ for (const view of listed) {
580
+ expect(resolved.get(view.name)).toBe(getSecret(view.name, agentGroupId));
581
+ }
582
+ }
583
+
584
+ it('rich mix: scoped+all + global+assigned + global+mode=all + name collision', () => {
585
+ const db = initTestDb();
586
+ runMigrations(db);
587
+ _setMasterKeyForTest(crypto.randomBytes(32));
588
+ seedAgentGroup(db, 'A', 'all');
589
+
590
+ putSecret('SCOPED_ONLY', 'sv', { agent_group_id: 'A' });
591
+ const assignedId = putSecret('GLOBAL_ASSIGNED', 'gv-assigned');
592
+ addAssignment(assignedId, 'A');
593
+ putSecret('GLOBAL_MODE_ALL', 'gv-mode-all');
594
+ putSecret('TOKEN', 'global-token');
595
+ putSecret('TOKEN', 'scoped-token', { agent_group_id: 'A' });
596
+
597
+ expectLockstep('A', ['SCOPED_ONLY', 'GLOBAL_ASSIGNED', 'GLOBAL_MODE_ALL', 'TOKEN']);
598
+
599
+ // Spot-check the collision picked the scoped row in BOTH views.
600
+ expect(resolveInjectableSecrets('A').get('TOKEN')).toBe('scoped-token');
601
+ expect(listInjectableSecretsForGroup('A').find((r) => r.name === 'TOKEN')?.scope).toBe('scoped');
602
+ });
603
+
604
+ it('mode=selective: mixed reachable + unreachable globals + scoped-with-assignment', () => {
605
+ const db = initTestDb();
606
+ runMigrations(db);
607
+ _setMasterKeyForTest(crypto.randomBytes(32));
608
+ seedAgentGroup(db, 'B', 'selective');
609
+
610
+ putSecret('UNREACHABLE_GLOBAL', 'gv'); // mode=selective + no assignment → excluded
611
+ const reachableId = putSecret('REACHABLE', 'gv2');
612
+ addAssignment(reachableId, 'B');
613
+ const scopedAssignedId = putSecret('SCOPED_AND_ASSIGNED', 'sv', { agent_group_id: 'B' });
614
+ addAssignment(scopedAssignedId, 'B');
615
+
616
+ expectLockstep('B', ['REACHABLE', 'SCOPED_AND_ASSIGNED']);
617
+ });
618
+
619
+ it('orphaned-scoped (selective + scoped + no assignment): both exclude', () => {
620
+ // The "structurally unreachable via putSecret" config — selective mode +
621
+ // scoped secret + no assignment row. paraclaw#127 closed the create path
622
+ // (putSecret auto-seeds the matching assignment), so to exercise the
623
+ // shared gate `(g.secret_mode='all' OR a.secret_id IS NOT NULL)` we
624
+ // construct the orphan directly. If a future SQL change makes one of the
625
+ // two functions accept it, this catches the drift.
626
+ const db = initTestDb();
627
+ runMigrations(db);
628
+ _setMasterKeyForTest(crypto.randomBytes(32));
629
+ seedAgentGroup(db, 'C', 'selective');
630
+
631
+ db.prepare(
632
+ `INSERT INTO secrets (id, name, value_encrypted, kind, agent_group_id, created_at, updated_at)
633
+ VALUES ('orphan-id', 'ORPHAN', 'ct', 'generic', 'C', datetime('now'), datetime('now'))`,
634
+ ).run();
635
+
636
+ expectLockstep('C', []);
637
+ });
638
+
639
+ it('unknown agent_group_id: both return empty (selective default)', () => {
640
+ putSecret('GLOBAL', 'v');
641
+ expectLockstep('does-not-exist', []);
642
+ });
643
+
644
+ it('empty secret store + mode=all: both return empty', () => {
645
+ const db = initTestDb();
646
+ runMigrations(db);
647
+ _setMasterKeyForTest(crypto.randomBytes(32));
648
+ seedAgentGroup(db, 'D', 'all');
649
+ expectLockstep('D', []);
650
+ });
651
+ });
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Regression coverage for paraclaw#96 — silent file clobber on mutated
3
+ * replays. After paraclaw#92 / #95 made duplicate-dispatch
4
+ * (sender-approval replay etc.) a warm path, the attachment-extraction
5
+ * step in writeSessionMessage must run only AFTER the row commits, or a
6
+ * mutated replay rewrites the on-disk file under the original
7
+ * messages_in.id while the DB row stays unchanged.
8
+ *
9
+ * Real session DBs (no execSync mock) — file-clobber-class hazards are
10
+ * easy to fake green with mocked filesystem state.
11
+ */
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+
15
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
16
+
17
+ import { openDb } from './db/connection.js';
18
+ import { initTestDb, closeDb, runMigrations, createAgentGroup } from './db/index.js';
19
+ import { initSessionFolder, inboundDbPath, sessionDir, writeSessionMessage } from './session-manager.js';
20
+
21
+ vi.mock('./config.js', async () => {
22
+ const actual = await vi.importActual('./config.js');
23
+ return { ...actual, DATA_DIR: '/tmp/paraclaw-test-attachments' };
24
+ });
25
+
26
+ const TEST_DIR = '/tmp/paraclaw-test-attachments';
27
+ const AG = 'ag-1';
28
+ const SESS = 'sess-attachments';
29
+
30
+ function nowIso(): string {
31
+ return new Date().toISOString();
32
+ }
33
+
34
+ function readRowContent(): string {
35
+ const db = openDb(inboundDbPath(AG, SESS));
36
+ const row = db.prepare('SELECT content FROM messages_in WHERE id = ?').get('msg-1') as { content: string };
37
+ db.close();
38
+ return row.content;
39
+ }
40
+
41
+ function inboxFilePath(filename: string): string {
42
+ return path.join(sessionDir(AG, SESS), 'inbox', 'msg-1', filename);
43
+ }
44
+
45
+ function makeMessageWithAttachment(dataB64: string) {
46
+ return {
47
+ id: 'msg-1',
48
+ kind: 'chat',
49
+ timestamp: nowIso(),
50
+ platformId: 'chan-1',
51
+ channelType: 'discord',
52
+ threadId: null as string | null,
53
+ content: JSON.stringify({
54
+ text: 'see attachment',
55
+ attachments: [{ name: 'photo.jpg', type: 'image', size: 9, data: dataB64 }],
56
+ }),
57
+ };
58
+ }
59
+
60
+ const ORIGINAL_BYTES = Buffer.from('first-pic');
61
+ const MUTATED_BYTES = Buffer.from('CLOBBERED');
62
+
63
+ beforeEach(() => {
64
+ if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
65
+ fs.mkdirSync(TEST_DIR, { recursive: true });
66
+
67
+ const db = initTestDb();
68
+ runMigrations(db);
69
+
70
+ createAgentGroup({
71
+ id: AG,
72
+ name: 'Test Agent',
73
+ folder: 'test-agent',
74
+ agent_provider: null,
75
+ created_at: nowIso(),
76
+ });
77
+ // Fresh-start a session manually — we don't need a messaging group for
78
+ // these tests, just the session folder + DBs.
79
+ const sessRow = {
80
+ id: SESS,
81
+ agent_group_id: AG,
82
+ messaging_group_id: null,
83
+ thread_id: null,
84
+ agent_provider: null,
85
+ status: 'active' as const,
86
+ container_status: 'stopped' as const,
87
+ last_active: null,
88
+ created_at: nowIso(),
89
+ };
90
+ db.prepare(
91
+ `INSERT INTO sessions (id, agent_group_id, messaging_group_id, thread_id, agent_provider,
92
+ status, container_status, last_active, created_at)
93
+ VALUES (@id, @agent_group_id, @messaging_group_id, @thread_id, @agent_provider,
94
+ @status, @container_status, @last_active, @created_at)`,
95
+ ).run(sessRow);
96
+ initSessionFolder(AG, SESS);
97
+ });
98
+
99
+ afterEach(() => {
100
+ closeDb();
101
+ if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
102
+ });
103
+
104
+ describe('writeSessionMessage — attachment extraction order (paraclaw#96)', () => {
105
+ it('fresh insert with attachment: row content has localPath, file written to inbox', () => {
106
+ writeSessionMessage(AG, SESS, makeMessageWithAttachment(ORIGINAL_BYTES.toString('base64')));
107
+
108
+ const parsed = JSON.parse(readRowContent());
109
+ expect(parsed.attachments).toHaveLength(1);
110
+ expect(parsed.attachments[0].localPath).toBe('inbox/msg-1/photo.jpg');
111
+ expect(parsed.attachments[0].data).toBeUndefined();
112
+
113
+ const onDisk = fs.readFileSync(inboxFilePath('photo.jpg'));
114
+ expect(onDisk.equals(ORIGINAL_BYTES)).toBe(true);
115
+ });
116
+
117
+ it('fresh insert without attachments: row content unchanged, no inbox dir created', () => {
118
+ writeSessionMessage(AG, SESS, {
119
+ id: 'msg-1',
120
+ kind: 'chat',
121
+ timestamp: nowIso(),
122
+ content: JSON.stringify({ text: 'no attachments here' }),
123
+ });
124
+
125
+ expect(JSON.parse(readRowContent()).text).toBe('no attachments here');
126
+ expect(fs.existsSync(path.join(sessionDir(AG, SESS), 'inbox'))).toBe(false);
127
+ });
128
+
129
+ it('duplicate dispatch with identical bytes: silently absorbed, file untouched', () => {
130
+ const msg = makeMessageWithAttachment(ORIGINAL_BYTES.toString('base64'));
131
+
132
+ writeSessionMessage(AG, SESS, msg);
133
+ const firstMtime = fs.statSync(inboxFilePath('photo.jpg')).mtimeMs;
134
+
135
+ // Sleep just enough to make a re-write detectable in mtime resolution.
136
+ const wait = Date.now() + 20;
137
+ while (Date.now() < wait) {
138
+ /* spin */
139
+ }
140
+
141
+ writeSessionMessage(AG, SESS, msg);
142
+
143
+ // File was NOT re-written — mtime unchanged.
144
+ expect(fs.statSync(inboxFilePath('photo.jpg')).mtimeMs).toBe(firstMtime);
145
+ expect(fs.readFileSync(inboxFilePath('photo.jpg')).equals(ORIGINAL_BYTES)).toBe(true);
146
+ });
147
+
148
+ it('duplicate dispatch with MUTATED bytes (replay hazard): on-disk file preserved byte-for-byte', () => {
149
+ // The exact failure shape paraclaw#96 calls out: a replay that re-uses
150
+ // the same messages_in.id but carries different attachment bytes. Pre-fix,
151
+ // the second call's extractAttachmentFiles ran before the dup-check and
152
+ // would have overwritten photo.jpg with MUTATED_BYTES while the DB row
153
+ // still pointed at the original commit.
154
+ writeSessionMessage(AG, SESS, makeMessageWithAttachment(ORIGINAL_BYTES.toString('base64')));
155
+ const rowAfterFirst = readRowContent();
156
+
157
+ writeSessionMessage(AG, SESS, makeMessageWithAttachment(MUTATED_BYTES.toString('base64')));
158
+
159
+ // 1. On-disk file is byte-for-byte the original — NO clobber.
160
+ const onDisk = fs.readFileSync(inboxFilePath('photo.jpg'));
161
+ expect(onDisk.equals(ORIGINAL_BYTES)).toBe(true);
162
+ expect(onDisk.equals(MUTATED_BYTES)).toBe(false);
163
+
164
+ // 2. Only the original row exists; the replay didn't slip a new row in.
165
+ const db = openDb(inboundDbPath(AG, SESS));
166
+ const rows = db.prepare('SELECT id, content FROM messages_in').all() as Array<{ id: string; content: string }>;
167
+ db.close();
168
+ expect(rows).toHaveLength(1);
169
+ expect(rows[0].content).toBe(rowAfterFirst);
170
+ });
171
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Integration coverage for paraclaw#97 — writeSessionMessage's dup-skip
3
+ * side effects beyond the SQL-layer assertions in #95.
4
+ *
5
+ * After paraclaw#92 / #95, ON CONFLICT(id) DO NOTHING absorbs the second
6
+ * INSERT, and writeSessionMessage gates two side effects on inserted=true:
7
+ * 1. session.last_active is NOT bumped on the dup
8
+ * 2. extractAttachmentFiles never runs (paraclaw#96 / #120)
9
+ * 3. A debug log fires so drops are observable
10
+ *
11
+ * The session-manager.attachments.test.ts file already covers #2 with
12
+ * sequential pairs. This file adds the integration-level invariants the
13
+ * issue calls out as still untested at the writeSessionMessage layer:
14
+ * last_active discipline, debug log shape, and dup-skip behavior under
15
+ * realistic dispatcher pressure (Promise.all'd same-id calls).
16
+ *
17
+ * Real session DBs + real fs — last_active and file invariants are the
18
+ * kind that mocks can fake green.
19
+ */
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+
23
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
24
+
25
+ import { openDb } from './db/connection.js';
26
+ import { initTestDb, closeDb, runMigrations, createAgentGroup, getSession } from './db/index.js';
27
+ import { log } from './log.js';
28
+ import { initSessionFolder, inboundDbPath, sessionDir, writeSessionMessage } from './session-manager.js';
29
+
30
+ vi.mock('./config.js', async () => {
31
+ const actual = await vi.importActual('./config.js');
32
+ return { ...actual, DATA_DIR: '/tmp/paraclaw-test-dup-skip' };
33
+ });
34
+
35
+ const TEST_DIR = '/tmp/paraclaw-test-dup-skip';
36
+ const AG = 'ag-1';
37
+ const SESS = 'sess-dup';
38
+
39
+ function nowIso(): string {
40
+ return new Date().toISOString();
41
+ }
42
+
43
+ function rowsInMessagesIn(): Array<{ id: string }> {
44
+ const db = openDb(inboundDbPath(AG, SESS));
45
+ const rows = db.prepare('SELECT id FROM messages_in').all() as Array<{ id: string }>;
46
+ db.close();
47
+ return rows;
48
+ }
49
+
50
+ function makeMessage(id: string) {
51
+ return {
52
+ id,
53
+ kind: 'chat',
54
+ timestamp: nowIso(),
55
+ platformId: 'chan-1',
56
+ channelType: 'discord',
57
+ threadId: null as string | null,
58
+ content: JSON.stringify({
59
+ text: 'hello',
60
+ attachments: [{ name: 'doc.bin', type: 'file', size: 4, data: Buffer.from('aaaa').toString('base64') }],
61
+ }),
62
+ };
63
+ }
64
+
65
+ beforeEach(() => {
66
+ if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
67
+ fs.mkdirSync(TEST_DIR, { recursive: true });
68
+
69
+ const db = initTestDb();
70
+ runMigrations(db);
71
+
72
+ createAgentGroup({
73
+ id: AG,
74
+ name: 'Test Agent',
75
+ folder: 'test-agent',
76
+ agent_provider: null,
77
+ created_at: nowIso(),
78
+ });
79
+ db.prepare(
80
+ `INSERT INTO sessions (id, agent_group_id, messaging_group_id, thread_id, agent_provider,
81
+ status, container_status, last_active, created_at)
82
+ VALUES (@id, @agent_group_id, @messaging_group_id, @thread_id, @agent_provider,
83
+ @status, @container_status, @last_active, @created_at)`,
84
+ ).run({
85
+ id: SESS,
86
+ agent_group_id: AG,
87
+ messaging_group_id: null,
88
+ thread_id: null,
89
+ agent_provider: null,
90
+ status: 'active' as const,
91
+ container_status: 'stopped' as const,
92
+ last_active: null,
93
+ created_at: nowIso(),
94
+ });
95
+ initSessionFolder(AG, SESS);
96
+ });
97
+
98
+ afterEach(() => {
99
+ closeDb();
100
+ if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
101
+ vi.restoreAllMocks();
102
+ });
103
+
104
+ describe('writeSessionMessage — dup-skip side effects (paraclaw#97)', () => {
105
+ it('does NOT bump session.last_active on a duplicate dispatch', async () => {
106
+ writeSessionMessage(AG, SESS, makeMessage('msg-1'));
107
+
108
+ const firstActive = getSession(SESS)!.last_active;
109
+ expect(firstActive).not.toBeNull();
110
+
111
+ // Sleep enough for any new ISO timestamp to differ from the first.
112
+ await new Promise((r) => setTimeout(r, 25));
113
+
114
+ writeSessionMessage(AG, SESS, makeMessage('msg-1'));
115
+
116
+ const secondActive = getSession(SESS)!.last_active;
117
+ expect(secondActive).toBe(firstActive);
118
+
119
+ expect(rowsInMessagesIn()).toHaveLength(1);
120
+ });
121
+
122
+ it('emits a debug log on dup-skip with the expected payload', () => {
123
+ const debugSpy = vi.spyOn(log, 'debug');
124
+
125
+ writeSessionMessage(AG, SESS, makeMessage('msg-2'));
126
+ writeSessionMessage(AG, SESS, makeMessage('msg-2'));
127
+
128
+ const dupCalls = debugSpy.mock.calls.filter(
129
+ ([msg]) => typeof msg === 'string' && msg.includes('messages_in id already present'),
130
+ );
131
+ expect(dupCalls).toHaveLength(1);
132
+
133
+ const [, fields] = dupCalls[0]!;
134
+ expect(fields).toMatchObject({
135
+ agentGroupId: AG,
136
+ sessionId: SESS,
137
+ messageId: 'msg-2',
138
+ });
139
+ });
140
+
141
+ it('absorbs N near-concurrent same-id dispatches: one row, one file, no spurious sibling files', async () => {
142
+ // Promise.all-style dispatcher pressure. better-sqlite3 is synchronous so
143
+ // these serialize on the event loop, but the test shape captures what a
144
+ // future async refactor would have to preserve: same-id calls collapse
145
+ // to a single row + a single attachment file regardless of fan-in.
146
+ const calls = Array.from({ length: 6 }, () => writeSessionMessage(AG, SESS, makeMessage('msg-burst')));
147
+ await Promise.all(calls);
148
+
149
+ expect(rowsInMessagesIn()).toHaveLength(1);
150
+
151
+ const inboxDir = path.join(sessionDir(AG, SESS), 'inbox', 'msg-burst');
152
+ expect(fs.existsSync(inboxDir)).toBe(true);
153
+ const files = fs.readdirSync(inboxDir);
154
+ expect(files).toEqual(['doc.bin']);
155
+ });
156
+
157
+ it('different ids in the same burst all land — dup-skip is keyed on id, not on burst', async () => {
158
+ // Sanity check that the dup-skip absorption is NOT overly broad — distinct
159
+ // messages_in.id values still get their own rows + their own inbox dirs.
160
+ await Promise.all([
161
+ writeSessionMessage(AG, SESS, makeMessage('msg-a')),
162
+ writeSessionMessage(AG, SESS, makeMessage('msg-b')),
163
+ writeSessionMessage(AG, SESS, makeMessage('msg-c')),
164
+ ]);
165
+
166
+ const ids = rowsInMessagesIn()
167
+ .map((r) => r.id)
168
+ .sort();
169
+ expect(ids).toEqual(['msg-a', 'msg-b', 'msg-c']);
170
+
171
+ expect(fs.readdirSync(path.join(sessionDir(AG, SESS), 'inbox')).sort()).toEqual(['msg-a', 'msg-b', 'msg-c']);
172
+ });
173
+ });