@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
|
@@ -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:
|
|
8
|
-
* (pre-0.1.0 installs auto-migrate from
|
|
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(
|
|
138
|
+
return path.join(HOME_DIR, p.slice(2));
|
|
128
139
|
}
|
|
129
140
|
if (p === '~') {
|
|
130
|
-
return
|
|
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
|
});
|
package/src/secrets/index.ts
CHANGED
|
@@ -94,22 +94,43 @@ export function putSecret(name: string, value: string, opts: PutSecretOpts = {})
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const id = crypto.randomUUID();
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* assignment row
|
|
296
|
-
*
|
|
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
|
+
}
|