@openparachute/agent 0.1.0 → 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.
- package/CHANGELOG.md +48 -0
- package/LICENSE +675 -21
- package/LICENSE-NANOCLAW-MIT +21 -0
- package/README.md +8 -1
- package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +1 -1
- package/package.json +2 -1
- package/scripts/init-cli-agent.ts +2 -1
- package/scripts/init-first-agent.ts +2 -1
- package/scripts/seed-discord.ts +2 -1
- package/src/channels/api-translator.test.ts +306 -0
- package/src/channels/api-translator.ts +214 -0
- package/src/config.ts +23 -3
- package/src/container-runtime.test.ts +101 -1
- package/src/container-runtime.ts +76 -1
- package/src/db/connection.migrate.test.ts +35 -2
- package/src/db/connection.ts +40 -5
- package/src/index.ts +6 -1
- package/src/mcp/tools/channels.test.ts +126 -0
- package/src/mcp/tools/channels.ts +33 -98
- package/src/modules/mount-security/expand-path.test.ts +82 -0
- package/src/modules/mount-security/index.ts +21 -10
- package/src/modules/permissions/sender-approval.test.ts +171 -0
- package/src/secrets/index.ts +127 -21
- package/src/secrets/secrets.test.ts +301 -4
- package/src/session-manager.attachments.test.ts +171 -0
- package/src/session-manager.dup-skip.test.ts +173 -0
- package/src/session-manager.ts +22 -4
- package/src/types.ts +4 -1
- package/src/web/routes/channels-mga-detail.test.ts +49 -2
- package/src/web/routes/channels.ts +25 -203
- package/src/web/routes/secrets.test.ts +46 -1
- package/src/web/routes/secrets.ts +35 -0
- package/src/web/server.ts +34 -13
- package/src/web/services-manifest.test.ts +37 -9
- package/src/web/services-manifest.ts +14 -9
- package/web/ui/index.html +2 -2
- package/web/ui/src/App.tsx +1 -1
- package/web/ui/src/lib/api.test.ts +2 -2
- package/web/ui/src/lib/api.ts +40 -2
- package/web/ui/src/lib/auth.test.ts +214 -1
- package/web/ui/src/lib/auth.ts +79 -22
- package/web/ui/src/routes/ChannelWireDetail.test.tsx +2 -2
- package/web/ui/src/routes/ChannelWireDetail.tsx +1 -1
- package/web/ui/src/routes/GroupDetail.test.tsx +206 -0
- package/web/ui/src/routes/GroupDetail.tsx +126 -1
- package/web/ui/src/routes/MessagingGroupDetail.test.tsx +1 -1
- package/web/ui/src/routes/SecretsList.tsx +22 -1
- 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
|
|
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
|
-
|
|
118
|
-
putSecret('
|
|
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.
|
|
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
|
+
});
|