@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +675 -21
  3. package/LICENSE-NANOCLAW-MIT +21 -0
  4. package/README.md +8 -1
  5. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +1 -1
  6. package/package.json +2 -1
  7. package/scripts/init-cli-agent.ts +2 -1
  8. package/scripts/init-first-agent.ts +2 -1
  9. package/scripts/seed-discord.ts +2 -1
  10. package/src/channels/api-translator.test.ts +306 -0
  11. package/src/channels/api-translator.ts +214 -0
  12. package/src/config.ts +23 -3
  13. package/src/container-runtime.test.ts +101 -1
  14. package/src/container-runtime.ts +76 -1
  15. package/src/db/connection.migrate.test.ts +35 -2
  16. package/src/db/connection.ts +40 -5
  17. package/src/index.ts +6 -1
  18. package/src/mcp/tools/channels.test.ts +126 -0
  19. package/src/mcp/tools/channels.ts +33 -98
  20. package/src/modules/mount-security/expand-path.test.ts +82 -0
  21. package/src/modules/mount-security/index.ts +21 -10
  22. package/src/modules/permissions/sender-approval.test.ts +171 -0
  23. package/src/secrets/index.ts +127 -21
  24. package/src/secrets/secrets.test.ts +301 -4
  25. package/src/session-manager.attachments.test.ts +171 -0
  26. package/src/session-manager.dup-skip.test.ts +173 -0
  27. package/src/session-manager.ts +22 -4
  28. package/src/types.ts +4 -1
  29. package/src/web/routes/channels-mga-detail.test.ts +49 -2
  30. package/src/web/routes/channels.ts +25 -203
  31. package/src/web/routes/secrets.test.ts +46 -1
  32. package/src/web/routes/secrets.ts +35 -0
  33. package/src/web/server.ts +34 -13
  34. package/src/web/services-manifest.test.ts +37 -9
  35. package/src/web/services-manifest.ts +14 -9
  36. package/web/ui/index.html +2 -2
  37. package/web/ui/src/App.tsx +1 -1
  38. package/web/ui/src/lib/api.test.ts +2 -2
  39. package/web/ui/src/lib/api.ts +40 -2
  40. package/web/ui/src/lib/auth.test.ts +214 -1
  41. package/web/ui/src/lib/auth.ts +79 -22
  42. package/web/ui/src/routes/ChannelWireDetail.test.tsx +2 -2
  43. package/web/ui/src/routes/ChannelWireDetail.tsx +1 -1
  44. package/web/ui/src/routes/GroupDetail.test.tsx +206 -0
  45. package/web/ui/src/routes/GroupDetail.tsx +126 -1
  46. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +1 -1
  47. package/web/ui/src/routes/SecretsList.tsx +22 -1
  48. package/web/ui/src/routes/VaultDetail.test.tsx +2 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `expandPath` resolves operator-supplied paths inside the mount-allowlist
3
+ * (`~/projects` etc.) against `HOME_DIR` from src/config.ts. paraclaw#99
4
+ * pulled the HOME-resolution out of this module so the precedence rule
5
+ * (`process.env.HOME` → `os.homedir()`) lives in one place; these tests pin
6
+ * the contract.
7
+ *
8
+ * `vi.resetModules()` is required because config.ts captures HOME_DIR at
9
+ * module load — tests that flip env vars must re-import both config and the
10
+ * mount-security module so the new HOME_DIR threads through.
11
+ */
12
+ import path from 'node:path';
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
14
+
15
+ const ORIGINAL_HOME = process.env.HOME;
16
+
17
+ beforeEach(() => {
18
+ vi.resetModules();
19
+ });
20
+
21
+ afterEach(() => {
22
+ if (ORIGINAL_HOME === undefined) delete process.env.HOME;
23
+ else process.env.HOME = ORIGINAL_HOME;
24
+ });
25
+
26
+ describe('expandPath HOME resolution', () => {
27
+ it("expands '~/foo' against config.HOME_DIR (default)", async () => {
28
+ process.env.HOME = '/Users/test-default';
29
+ const cfg = await import('../../config.js');
30
+ const { expandPath } = await import('./index.js');
31
+ expect(cfg.HOME_DIR).toBe('/Users/test-default');
32
+ expect(expandPath('~/projects')).toBe(path.join('/Users/test-default', 'projects'));
33
+ });
34
+
35
+ it("expands bare '~' to config.HOME_DIR", async () => {
36
+ process.env.HOME = '/Users/test-bare-tilde';
37
+ const { expandPath } = await import('./index.js');
38
+ expect(expandPath('~')).toBe('/Users/test-bare-tilde');
39
+ });
40
+
41
+ it('passes absolute paths through path.resolve unchanged', async () => {
42
+ process.env.HOME = '/Users/test-abs';
43
+ const { expandPath } = await import('./index.js');
44
+ // Absolute paths should NOT consult HOME_DIR — they resolve as-is.
45
+ expect(expandPath('/var/data/x')).toBe('/var/data/x');
46
+ });
47
+
48
+ it('honors HOME override at module load (sandbox-style override)', async () => {
49
+ // The override path: PARACHUTE_HOME does NOT route mount-allowlist (#99
50
+ // path 2 — operator-host policy is intentionally separate from runtime
51
+ // state), but the bare HOME env var IS honored by config.HOME_DIR for
52
+ // operators who reroute their entire shell session. Pin that flow.
53
+ process.env.HOME = '/tmp/sandbox-home-99';
54
+ const cfg = await import('../../config.js');
55
+ const { expandPath } = await import('./index.js');
56
+ expect(cfg.HOME_DIR).toBe('/tmp/sandbox-home-99');
57
+ expect(expandPath('~/repos')).toBe('/tmp/sandbox-home-99/repos');
58
+ // ALLOWLIST_DIR derives from HOME_DIR — it should follow.
59
+ expect(cfg.ALLOWLIST_DIR).toBe('/tmp/sandbox-home-99/.config/parachute-agent');
60
+ });
61
+
62
+ it('does NOT route through PARACHUTE_HOME (operator-policy stays at <HOME>/.config)', async () => {
63
+ // paraclaw#99 path 2 contract: PARACHUTE_HOME reroutes runtime state
64
+ // (DB + master.key) but NOT operator-host policy (mount-allowlist).
65
+ // Pin the split — if a future refactor accidentally collapses the two,
66
+ // sandboxes would silently see different mount permissions than the
67
+ // live install they share a host with.
68
+ process.env.HOME = '/Users/operator';
69
+ process.env.PARACHUTE_HOME = '/tmp/sandbox-home-collapse-check';
70
+ try {
71
+ const cfg = await import('../../config.js');
72
+ expect(cfg.PARACHUTE_DIR).toBe('/tmp/sandbox-home-collapse-check');
73
+ // CENTRAL_DB_DIR follows PARACHUTE_HOME — runtime state.
74
+ expect(cfg.CENTRAL_DB_DIR).toBe('/tmp/sandbox-home-collapse-check/agent');
75
+ // ALLOWLIST_DIR does NOT — operator-host policy.
76
+ expect(cfg.ALLOWLIST_DIR).toBe('/Users/operator/.config/parachute-agent');
77
+ expect(cfg.MOUNT_ALLOWLIST_PATH).toBe('/Users/operator/.config/parachute-agent/mount-allowlist.json');
78
+ } finally {
79
+ delete process.env.PARACHUTE_HOME;
80
+ }
81
+ });
82
+ });
@@ -4,14 +4,22 @@
4
4
  * Validates additional mounts against an allowlist stored OUTSIDE the project root.
5
5
  * This prevents container agents from modifying security configuration.
6
6
  *
7
- * Allowlist location: ~/.config/parachute-agent/mount-allowlist.json
8
- * (pre-0.1.0 installs auto-migrate from ~/.config/paraclaw/ on startup —
9
- * see migrateLegacyAllowlistDir below).
7
+ * Allowlist location: `<HOME>/.config/parachute-agent/mount-allowlist.json`
8
+ * (pre-0.1.0 installs auto-migrate from `<HOME>/.config/paraclaw/` on startup —
9
+ * see migrateLegacyAllowlistDir below). Path stays under `<HOME>/.config/`,
10
+ * NOT under `PARACHUTE_DIR`, because mount-allowlist is operator-host policy
11
+ * rather than per-install runtime state — see the doc-block on `ALLOWLIST_DIR`
12
+ * in src/config.ts (paraclaw#99).
13
+ *
14
+ * `HOME_DIR` is imported from src/config.ts so the precedence rule
15
+ * (`process.env.HOME` → `os.homedir()`) lives in one place. expandPath uses
16
+ * the same HOME_DIR for `~`-expansion in operator-supplied paths inside the
17
+ * allowlist (`~/projects` etc.), ensuring expansion agrees with the rest of
18
+ * the host process.
10
19
  */
11
20
  import fs from 'fs';
12
- import os from 'os';
13
21
  import path from 'path';
14
- import { ALLOWLIST_DIR, LEGACY_ALLOWLIST_DIR, MOUNT_ALLOWLIST_PATH } from '../../config.js';
22
+ import { ALLOWLIST_DIR, HOME_DIR, LEGACY_ALLOWLIST_DIR, MOUNT_ALLOWLIST_PATH } from '../../config.js';
15
23
  import { log } from '../../log.js';
16
24
 
17
25
  export interface AdditionalMount {
@@ -119,15 +127,18 @@ export function loadMountAllowlist(): MountAllowlist | null {
119
127
  }
120
128
 
121
129
  /**
122
- * Expand ~ to home directory and resolve to absolute path
130
+ * Expand ~ to home directory and resolve to absolute path. `HOME_DIR` comes
131
+ * from src/config.ts so a future change to the precedence rule
132
+ * (`HOME` → `os.homedir()`) is one edit upstream rather than redrawn here.
133
+ * Exported for direct test coverage of the expansion (paraclaw#99); not
134
+ * intended for use outside this module.
123
135
  */
124
- function expandPath(p: string): string {
125
- const homeDir = process.env.HOME || os.homedir();
136
+ export function expandPath(p: string): string {
126
137
  if (p.startsWith('~/')) {
127
- return path.join(homeDir, p.slice(2));
138
+ return path.join(HOME_DIR, p.slice(2));
128
139
  }
129
140
  if (p === '~') {
130
- return homeDir;
141
+ return HOME_DIR;
131
142
  }
132
143
  return path.resolve(p);
133
144
  }
@@ -10,13 +10,23 @@
10
10
  * - Approve path: member added, original message replayed via routeInbound,
11
11
  * container woken
12
12
  * - Deny path: pending row deleted, no member added
13
+ * - Approve replay with attachment: row + file land cleanly at the
14
+ * namespaced messages_in.id path (paraclaw#97)
15
+ * - Approve replay with MUTATED original_message: on-disk attachment file
16
+ * is preserved byte-for-byte; the dup-skip path absorbs the second write
17
+ * so a path-normalization or any pre-replay mutation can't clobber state
18
+ * that's already committed (paraclaw#97 — #96 invariant under the
19
+ * sender-approval entry point)
13
20
  */
14
21
  import fs from 'fs';
22
+ import path from 'path';
15
23
  import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
16
24
 
25
+ import { openDb } from '../../db/connection.js';
17
26
  import { initTestDb, closeDb, runMigrations } from '../../db/index.js';
18
27
  import { createAgentGroup } from '../../db/agent-groups.js';
19
28
  import { createMessagingGroup, createMessagingGroupAgent } from '../../db/messaging-groups.js';
29
+ import { inboundDbPath, sessionDir } from '../../session-manager.js';
20
30
  import { upsertUser } from './db/users.js';
21
31
  import { grantRole } from './db/user-roles.js';
22
32
 
@@ -467,4 +477,165 @@ describe('unknown-sender request_approval flow', () => {
467
477
  .get('tg:stranger', 'ag-1');
468
478
  expect(member).toBeDefined();
469
479
  });
480
+
481
+ // ── paraclaw#97: replay-path coverage ──────────────────────────────────
482
+ //
483
+ // The unit tests above prove the response handler's bookkeeping (member
484
+ // added, pending row cleared, wake fired). The two tests below assert the
485
+ // full chain through routeInbound → writeSessionMessage on a message
486
+ // carrying a real attachment, plus the #96 file-clobber invariant under
487
+ // this entry point.
488
+
489
+ function strangerWithAttachment(textValue: string, attachmentBytes: Buffer) {
490
+ return {
491
+ channelType: 'telegram',
492
+ platformId: 'chat-123',
493
+ threadId: null,
494
+ message: {
495
+ id: 'tg-msg-with-att',
496
+ kind: 'chat' as const,
497
+ content: JSON.stringify({
498
+ senderId: 'tg:stranger',
499
+ senderName: 'Stranger',
500
+ text: textValue,
501
+ attachments: [
502
+ {
503
+ name: 'photo.jpg',
504
+ type: 'image',
505
+ size: attachmentBytes.length,
506
+ data: attachmentBytes.toString('base64'),
507
+ },
508
+ ],
509
+ }),
510
+ timestamp: now(),
511
+ },
512
+ };
513
+ }
514
+
515
+ it('approve replay → attachment lands cleanly at the namespaced messages_in.id path (paraclaw#97)', async () => {
516
+ const ORIGINAL_BYTES = Buffer.from('first-pic');
517
+ const event = strangerWithAttachment('see photo', ORIGINAL_BYTES);
518
+
519
+ const { routeInbound } = await import('../../router.js');
520
+ const { getResponseHandlers } = await import('../../response-registry.js');
521
+
522
+ // First route: gate denies (request_approval), pending row created. The
523
+ // wired agent has ignored_message_policy='drop', so no accumulate write
524
+ // happens — the replay will be the first writer of this messages_in.id.
525
+ await routeInbound(event);
526
+ await new Promise((r) => setTimeout(r, 10));
527
+
528
+ const { getDb } = await import('../../db/connection.js');
529
+ const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string };
530
+ expect(pending).toBeDefined();
531
+
532
+ for (const handler of getResponseHandlers()) {
533
+ const claimed = await handler({
534
+ questionId: pending.id,
535
+ value: 'approve',
536
+ userId: 'owner',
537
+ channelType: 'telegram',
538
+ platformId: 'dm-owner',
539
+ threadId: null,
540
+ });
541
+ if (claimed) break;
542
+ }
543
+
544
+ // The replay's deliverToAgent created the session — find it for the
545
+ // wired agent group and assert the row + file landed at the right spot.
546
+ const sess = getDb().prepare('SELECT id FROM sessions WHERE agent_group_id = ?').get('ag-1') as { id: string };
547
+ expect(sess).toBeDefined();
548
+
549
+ const inboundDb = openDb(inboundDbPath('ag-1', sess.id));
550
+ const rows = inboundDb.prepare('SELECT id, content FROM messages_in').all() as Array<{
551
+ id: string;
552
+ content: string;
553
+ }>;
554
+ inboundDb.close();
555
+
556
+ expect(rows).toHaveLength(1);
557
+ // messageIdForAgent namespaces the platform id with agent_group_id so a
558
+ // multi-agent fan-out can't collide on messages_in.id (router.ts).
559
+ const namespacedId = 'tg-msg-with-att:ag-1';
560
+ expect(rows[0].id).toBe(namespacedId);
561
+
562
+ // Row content carries localPath after extractAttachmentFiles ran post-
563
+ // commit; inline base64 is gone.
564
+ const parsed = JSON.parse(rows[0].content);
565
+ expect(parsed.attachments[0].localPath).toBe(`inbox/${namespacedId}/photo.jpg`);
566
+ expect(parsed.attachments[0].data).toBeUndefined();
567
+
568
+ const filePath = path.join(sessionDir('ag-1', sess.id), 'inbox', namespacedId, 'photo.jpg');
569
+ expect(fs.existsSync(filePath)).toBe(true);
570
+ expect(fs.readFileSync(filePath).equals(ORIGINAL_BYTES)).toBe(true);
571
+ });
572
+
573
+ it('approve replay with MUTATED original_message: on-disk file preserved (paraclaw#97 — #96 invariant)', async () => {
574
+ // Switch the wired agent to accumulate so the gate-denied first attempt
575
+ // writes the row + extracts the file BEFORE the approval card is acted
576
+ // on. This is the racing-dispatch shape #92 caught: two writers for the
577
+ // same messages_in.id, one from accumulate-on-gate-deny, one from the
578
+ // approval replay.
579
+ const { getDb } = await import('../../db/connection.js');
580
+ getDb()
581
+ .prepare(`UPDATE messaging_group_agents SET ignored_message_policy = 'accumulate' WHERE id = ?`)
582
+ .run('mga-1');
583
+
584
+ const ORIGINAL_BYTES = Buffer.from('first-pic');
585
+ const MUTATED_BYTES = Buffer.from('CLOBBERED');
586
+ const event = strangerWithAttachment('see photo', ORIGINAL_BYTES);
587
+
588
+ const { routeInbound } = await import('../../router.js');
589
+ const { getResponseHandlers } = await import('../../response-registry.js');
590
+
591
+ // First route: gate denies, but accumulate writes the row + extracts the
592
+ // file with ORIGINAL_BYTES.
593
+ await routeInbound(event);
594
+ await new Promise((r) => setTimeout(r, 10));
595
+
596
+ const sess = getDb().prepare('SELECT id FROM sessions WHERE agent_group_id = ?').get('ag-1') as { id: string };
597
+ expect(sess).toBeDefined();
598
+ const namespacedId = 'tg-msg-with-att:ag-1';
599
+ const filePath = path.join(sessionDir('ag-1', sess.id), 'inbox', namespacedId, 'photo.jpg');
600
+ expect(fs.readFileSync(filePath).equals(ORIGINAL_BYTES)).toBe(true);
601
+
602
+ // Mutate the pending row's original_message to carry MUTATED_BYTES. This
603
+ // mirrors any pre-replay normalization (path replacement, ContentRecord
604
+ // re-encoding, retry with re-fetched payload) that produces a JSON event
605
+ // whose attachment bytes don't match what's already on disk.
606
+ const pending = getDb().prepare('SELECT id FROM pending_sender_approvals').get() as { id: string };
607
+ expect(pending).toBeDefined();
608
+ const mutatedEvent = strangerWithAttachment('see photo', MUTATED_BYTES);
609
+ getDb()
610
+ .prepare('UPDATE pending_sender_approvals SET original_message = ? WHERE id = ?')
611
+ .run(JSON.stringify(mutatedEvent), pending.id);
612
+
613
+ // Approve. Replay's writeSessionMessage hits ON CONFLICT (id already
614
+ // present from the accumulate write), so extractAttachmentFiles never
615
+ // runs and the on-disk file stays put.
616
+ for (const handler of getResponseHandlers()) {
617
+ const claimed = await handler({
618
+ questionId: pending.id,
619
+ value: 'approve',
620
+ userId: 'owner',
621
+ channelType: 'telegram',
622
+ platformId: 'dm-owner',
623
+ threadId: null,
624
+ });
625
+ if (claimed) break;
626
+ }
627
+
628
+ // 1. File on disk is byte-for-byte the original — no mutated clobber.
629
+ const onDisk = fs.readFileSync(filePath);
630
+ expect(onDisk.equals(ORIGINAL_BYTES)).toBe(true);
631
+ expect(onDisk.equals(MUTATED_BYTES)).toBe(false);
632
+
633
+ // 2. Exactly one row in messages_in (the accumulate write); the replay
634
+ // didn't slip a second row in.
635
+ const inboundDb = openDb(inboundDbPath('ag-1', sess.id));
636
+ const rows = inboundDb.prepare('SELECT id FROM messages_in').all() as Array<{ id: string }>;
637
+ inboundDb.close();
638
+ expect(rows).toHaveLength(1);
639
+ expect(rows[0].id).toBe(namespacedId);
640
+ });
470
641
  });
@@ -94,22 +94,43 @@ export function putSecret(name: string, value: string, opts: PutSecretOpts = {})
94
94
  }
95
95
 
96
96
  const id = crypto.randomUUID();
97
- db()
98
- .prepare(
99
- `INSERT INTO secrets
100
- (id, name, value_encrypted, kind, agent_group_id, created_at, updated_at)
101
- VALUES
102
- (@id, @name, @value_encrypted, @kind, @agent_group_id, @created_at, @updated_at)`,
103
- )
104
- .run({
105
- id,
106
- name,
107
- value_encrypted: ct,
108
- kind,
109
- agent_group_id: agentGroupId,
110
- created_at: now,
111
- updated_at: now,
112
- });
97
+ // Auto-seed the matching `secret_assignments` row when the secret is
98
+ // scoped to a group (paraclaw#127). For a scoped secret the only valid
99
+ // assignment-row pair is (id, owning_group); the resolver only injects
100
+ // when `s.agent_group_id = g.id OR s.agent_group_id IS NULL`, so an
101
+ // assignment elsewhere is meaningless. Without this seed, scoped
102
+ // creates land orphaned under the default `selective` group mode and
103
+ // are silently invisible to `resolveInjectableSecrets`. INSERT path
104
+ // only — UPDATE/rotate leaves the existing assignment set alone.
105
+ // ON CONFLICT DO NOTHING for idempotency-on-replay (the constraint
106
+ // already exists in `replaceAssignments` for the same reason).
107
+ db().transaction(() => {
108
+ db()
109
+ .prepare(
110
+ `INSERT INTO secrets
111
+ (id, name, value_encrypted, kind, agent_group_id, created_at, updated_at)
112
+ VALUES
113
+ (@id, @name, @value_encrypted, @kind, @agent_group_id, @created_at, @updated_at)`,
114
+ )
115
+ .run({
116
+ id,
117
+ name,
118
+ value_encrypted: ct,
119
+ kind,
120
+ agent_group_id: agentGroupId,
121
+ created_at: now,
122
+ updated_at: now,
123
+ });
124
+ if (agentGroupId !== null) {
125
+ db()
126
+ .prepare(
127
+ `INSERT INTO secret_assignments (secret_id, agent_group_id, created_at)
128
+ VALUES (@secret_id, @agent_group_id, @created_at)
129
+ ON CONFLICT (secret_id, agent_group_id) DO NOTHING`,
130
+ )
131
+ .run({ secret_id: id, agent_group_id: agentGroupId, created_at: now });
132
+ }
133
+ })();
113
134
  return id;
114
135
  }
115
136
 
@@ -289,11 +310,12 @@ export interface StaleSession {
289
310
  * Note on a subtle asymmetry: `resolveInjectableSecrets` additionally gates
290
311
  * scoped secrets through `(secret_mode='all' OR assignment row exists)` on
291
312
  * the recipient group. The SQL here accepts the scoped match unconditionally.
292
- * The asymmetry is benign — the only configs where it would diverge (a
293
- * scoped secret paired with its parent group in `selective` mode and no
294
- * assignment row) are unreachable via the UI, which always seeds an
295
- * assignment row when scoping. If a future code path makes that config
296
- * reachable, tighten the SQL to add the same gate.
313
+ * The asymmetry is benign — the only config where it would diverge (a scoped
314
+ * secret in a `selective`-mode group with no assignment row) is structurally
315
+ * unreachable from `putSecret`: paraclaw#127 made the INSERT path auto-seed
316
+ * the (id, owning_group) assignment row in the same transaction. If a future
317
+ * code path bypasses `putSecret` and writes the orphan state directly, tighten
318
+ * the SQL to add the same gate.
297
319
  *
298
320
  * The host injects env vars at spawn time only — there is no in-process
299
321
  * update path. This helper powers the post-save banner that prompts the
@@ -353,3 +375,87 @@ export function getSecretById(id: string): SecretRow | undefined {
353
375
  .prepare<SecretRow>(`SELECT id, name, kind, agent_group_id, created_at, updated_at FROM secrets WHERE id = ?`)
354
376
  .get(id);
355
377
  }
378
+
379
+ /**
380
+ * Why a secret lands in a particular agent group's injectable set:
381
+ * - `scoped` — secret is owned by this group (`s.agent_group_id = g.id`).
382
+ * - `assigned` — global secret with an explicit `secret_assignments` row
383
+ * pointing at this group.
384
+ * - `global` — global secret with no assignment row, included only because
385
+ * the recipient group is in `secret_mode='all'`.
386
+ *
387
+ * When a global has BOTH an assignment row AND `secret_mode='all'`, we report
388
+ * `assigned` — the explicit row reflects deliberate operator intent, while
389
+ * mode='all' is a blanket setting; surfacing the more-specific reason makes
390
+ * the GroupDetail page actionable ("revoke this assignment" vs "flip to
391
+ * selective"). See paraclaw#104.
392
+ */
393
+ export type SecretInclusionScope = 'global' | 'scoped' | 'assigned';
394
+
395
+ export interface InjectableSecretView extends SecretRow {
396
+ scope: SecretInclusionScope;
397
+ }
398
+
399
+ /**
400
+ * Metadata-only mirror of `resolveInjectableSecrets` for the GroupDetail
401
+ * "Secrets" panel. Returns the same row set (subject to the same SQL gate)
402
+ * tagged with the inclusion reason — never decrypts. Caller is the read-only
403
+ * `GET /api/groups/:folder/secrets` route.
404
+ *
405
+ * The SQL mirrors `resolveInjectableSecrets` (the `(s.agent_group_id = g.id
406
+ * OR s.agent_group_id IS NULL)` row predicate gated by `(secret_mode='all'
407
+ * OR assignment exists)`) so the panel cannot disagree with what the
408
+ * container will actually receive at spawn time. Drift here would defeat
409
+ * the entire point of #104 — keep them in lockstep. If you change either,
410
+ * change both.
411
+ *
412
+ * `ORDER BY s.agent_group_id IS NULL` puts scoped rows first so the
413
+ * dedupe-by-name loop honors the "scoped wins on collision" rule
414
+ * `resolveInjectableSecrets` enforces.
415
+ */
416
+ export function listInjectableSecretsForGroup(agentGroupId: string): InjectableSecretView[] {
417
+ const rows = db()
418
+ .prepare<{
419
+ id: string;
420
+ name: string;
421
+ kind: SecretKind;
422
+ agent_group_id: string | null;
423
+ created_at: string;
424
+ updated_at: string;
425
+ assignment_present: number;
426
+ }>(
427
+ `SELECT s.id, s.name, s.kind, s.agent_group_id, s.created_at, s.updated_at,
428
+ CASE WHEN a.secret_id IS NULL THEN 0 ELSE 1 END AS assignment_present
429
+ FROM secrets s
430
+ LEFT JOIN secret_assignments a
431
+ ON a.secret_id = s.id
432
+ AND a.agent_group_id = @agent_group_id
433
+ LEFT JOIN agent_groups g
434
+ ON g.id = @agent_group_id
435
+ WHERE (s.agent_group_id = @agent_group_id OR s.agent_group_id IS NULL)
436
+ AND (g.secret_mode = 'all' OR a.secret_id IS NOT NULL)
437
+ ORDER BY s.agent_group_id IS NULL, s.name`,
438
+ )
439
+ .all({ agent_group_id: agentGroupId });
440
+
441
+ const out: InjectableSecretView[] = [];
442
+ const seen = new Set<string>();
443
+ for (const row of rows) {
444
+ if (seen.has(row.name)) continue;
445
+ seen.add(row.name);
446
+ let scope: SecretInclusionScope;
447
+ if (row.agent_group_id === agentGroupId) scope = 'scoped';
448
+ else if (row.assignment_present === 1) scope = 'assigned';
449
+ else scope = 'global';
450
+ out.push({
451
+ id: row.id,
452
+ name: row.name,
453
+ kind: row.kind,
454
+ agent_group_id: row.agent_group_id,
455
+ created_at: row.created_at,
456
+ updated_at: row.updated_at,
457
+ scope,
458
+ });
459
+ }
460
+ return out;
461
+ }