@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
@@ -0,0 +1,206 @@
1
+ /**
2
+ * GroupDetail "Secrets" panel — paraclaw#104.
3
+ *
4
+ * Asserts the three contracts the panel must hold:
5
+ * 1. Each row's `scope` field renders a visible badge (`scoped` / `assigned`
6
+ * / `global`). Drift between badge text and panel intent would defeat
7
+ * the entire point of #104.
8
+ * 2. Empty state distinguishes between mode='selective' (read as "by
9
+ * design") and mode='all' (read as "create a secret").
10
+ * 3. Click-through builds `/secrets?edit=<id>`. SecretsList's deep-link
11
+ * handler is exercised separately; we just check the link target.
12
+ *
13
+ * Tests mock the api module — no live server, no auth state needed.
14
+ */
15
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
16
+ import { MemoryRouter, Route, Routes } from 'react-router-dom';
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
18
+
19
+ import * as api from '../lib/api.ts';
20
+ import { GroupDetail } from './GroupDetail.tsx';
21
+
22
+ vi.mock('../lib/api.ts', async () => {
23
+ const actual = await vi.importActual<typeof api>('../lib/api.ts');
24
+ return {
25
+ ...actual,
26
+ getGroup: vi.fn(),
27
+ getGroupAgentProvider: vi.fn(),
28
+ listGroupInjectableSecrets: vi.fn(),
29
+ };
30
+ });
31
+
32
+ function renderAt(path: string) {
33
+ return render(
34
+ <MemoryRouter initialEntries={[path]}>
35
+ <Routes>
36
+ <Route path="/groups/:folder" element={<GroupDetail />} />
37
+ </Routes>
38
+ </MemoryRouter>,
39
+ );
40
+ }
41
+
42
+ const baseGroup: api.AgentGroupView = {
43
+ id: 'g1',
44
+ name: 'research',
45
+ folder: 'research',
46
+ agent_provider: null,
47
+ secret_mode: 'all',
48
+ created_at: '2026-04-20T10:00:00Z',
49
+ vault: null,
50
+ status: null,
51
+ };
52
+
53
+ const emptyProvider: api.AgentProviderView = {
54
+ source: null,
55
+ hasApiKey: false,
56
+ serverUrl: null,
57
+ updatedAt: null,
58
+ };
59
+
60
+ beforeEach(() => {
61
+ vi.mocked(api.getGroupAgentProvider).mockResolvedValue({
62
+ agentGroupId: 'g1',
63
+ overridden: false,
64
+ override: emptyProvider,
65
+ effective: emptyProvider,
66
+ });
67
+ });
68
+
69
+ afterEach(() => {
70
+ vi.clearAllMocks();
71
+ });
72
+
73
+ describe('GroupDetail — Secrets panel (paraclaw#104)', () => {
74
+ it('renders one badge per scope (scoped / assigned / global)', async () => {
75
+ vi.mocked(api.getGroup).mockResolvedValue({ ...baseGroup, secret_mode: 'all' });
76
+ vi.mocked(api.listGroupInjectableSecrets).mockResolvedValue([
77
+ {
78
+ id: 'sec-scoped',
79
+ name: 'SCOPED_TOKEN',
80
+ kind: 'channel-token',
81
+ agentGroupId: 'g1',
82
+ scope: 'scoped',
83
+ createdAt: '2026-05-01T00:00:00Z',
84
+ updatedAt: '2026-05-01T00:00:00Z',
85
+ },
86
+ {
87
+ id: 'sec-assigned',
88
+ name: 'ASSIGNED_TOKEN',
89
+ kind: 'api-key',
90
+ agentGroupId: null,
91
+ scope: 'assigned',
92
+ createdAt: '2026-05-01T00:00:00Z',
93
+ updatedAt: '2026-05-01T00:00:00Z',
94
+ },
95
+ {
96
+ id: 'sec-global',
97
+ name: 'GLOBAL_TOKEN',
98
+ kind: 'generic',
99
+ agentGroupId: null,
100
+ scope: 'global',
101
+ createdAt: '2026-05-01T00:00:00Z',
102
+ updatedAt: '2026-05-01T00:00:00Z',
103
+ },
104
+ ]);
105
+
106
+ renderAt('/groups/research');
107
+
108
+ await waitFor(() => {
109
+ expect(screen.getByText('SCOPED_TOKEN')).toBeInTheDocument();
110
+ });
111
+
112
+ expect(screen.getByText('SCOPED_TOKEN')).toBeInTheDocument();
113
+ expect(screen.getByText('ASSIGNED_TOKEN')).toBeInTheDocument();
114
+ expect(screen.getByText('GLOBAL_TOKEN')).toBeInTheDocument();
115
+
116
+ // Each scope label appears exactly once — the badge text must match the
117
+ // wire `scope` field one-to-one.
118
+ expect(screen.getByText('scoped')).toBeInTheDocument();
119
+ expect(screen.getByText('assigned')).toBeInTheDocument();
120
+ expect(screen.getByText('global')).toBeInTheDocument();
121
+ });
122
+
123
+ it('click-through targets /secrets?edit=<id>', async () => {
124
+ vi.mocked(api.getGroup).mockResolvedValue({ ...baseGroup, secret_mode: 'all' });
125
+ vi.mocked(api.listGroupInjectableSecrets).mockResolvedValue([
126
+ {
127
+ id: 'sec-1',
128
+ name: 'TOKEN',
129
+ kind: 'generic',
130
+ agentGroupId: 'g1',
131
+ scope: 'scoped',
132
+ createdAt: '2026-05-01T00:00:00Z',
133
+ updatedAt: '2026-05-01T00:00:00Z',
134
+ },
135
+ ]);
136
+
137
+ renderAt('/groups/research');
138
+
139
+ await waitFor(() => {
140
+ expect(screen.getByText('TOKEN')).toBeInTheDocument();
141
+ });
142
+
143
+ const link = screen.getByText('TOKEN').closest('a');
144
+ expect(link).toHaveAttribute('href', '/secrets?edit=sec-1');
145
+ });
146
+
147
+ it('empty state under selective mode reads as by-design, not broken', async () => {
148
+ vi.mocked(api.getGroup).mockResolvedValue({ ...baseGroup, secret_mode: 'selective' });
149
+ vi.mocked(api.listGroupInjectableSecrets).mockResolvedValue([]);
150
+
151
+ renderAt('/groups/research');
152
+
153
+ await waitFor(() => {
154
+ expect(screen.getByText(/No secrets reach this group/)).toBeInTheDocument();
155
+ });
156
+
157
+ // selective-mode copy mentions assignment rows, not "create a secret".
158
+ expect(screen.getByText(/explicit assignment row/)).toBeInTheDocument();
159
+ });
160
+
161
+ it('empty state under mode=all suggests creating a secret', async () => {
162
+ vi.mocked(api.getGroup).mockResolvedValue({ ...baseGroup, secret_mode: 'all' });
163
+ vi.mocked(api.listGroupInjectableSecrets).mockResolvedValue([]);
164
+
165
+ renderAt('/groups/research');
166
+
167
+ await waitFor(() => {
168
+ expect(screen.getByText(/No secrets reach this group/)).toBeInTheDocument();
169
+ });
170
+
171
+ expect(screen.getByText(/Create a scoped secret/)).toBeInTheDocument();
172
+ });
173
+
174
+ it('error state surfaces a Retry button that re-invokes the fetch (paraclaw#128)', async () => {
175
+ vi.mocked(api.getGroup).mockResolvedValue({ ...baseGroup, secret_mode: 'all' });
176
+ vi.mocked(api.listGroupInjectableSecrets)
177
+ .mockRejectedValueOnce(new Error('boom: transient 500'))
178
+ .mockResolvedValueOnce([
179
+ {
180
+ id: 'sec-1',
181
+ name: 'TOKEN',
182
+ kind: 'generic',
183
+ agentGroupId: 'g1',
184
+ scope: 'scoped',
185
+ createdAt: '2026-05-01T00:00:00Z',
186
+ updatedAt: '2026-05-01T00:00:00Z',
187
+ },
188
+ ]);
189
+
190
+ renderAt('/groups/research');
191
+
192
+ await waitFor(() => {
193
+ expect(screen.getByText(/Couldn't load secrets/)).toBeInTheDocument();
194
+ });
195
+ expect(screen.getByText('boom: transient 500')).toBeInTheDocument();
196
+
197
+ const retry = screen.getByRole('button', { name: 'Retry' });
198
+ fireEvent.click(retry);
199
+
200
+ await waitFor(() => {
201
+ expect(screen.getByText('TOKEN')).toBeInTheDocument();
202
+ });
203
+ expect(screen.queryByText(/Couldn't load secrets/)).not.toBeInTheDocument();
204
+ expect(api.listGroupInjectableSecrets).toHaveBeenCalledTimes(2);
205
+ });
206
+ });
@@ -12,12 +12,15 @@ import {
12
12
  detachVault,
13
13
  getGroup,
14
14
  getGroupAgentProvider,
15
+ listGroupInjectableSecrets,
15
16
  setGroupAgentProvider,
16
17
  spawnSession,
17
18
  type AgentGroupView,
18
19
  type AgentProviderSource,
19
20
  type GroupAgentProviderView,
20
21
  type GroupStatus,
22
+ type InjectableSecretView,
23
+ type SecretInclusionScope,
21
24
  type VaultScope,
22
25
  } from '../lib/api.ts';
23
26
 
@@ -437,6 +440,8 @@ export function GroupDetail() {
437
440
  </div>
438
441
  )}
439
442
 
443
+ {folder && <SecretsSection folder={folder} secretMode={group.secret_mode} />}
444
+
440
445
  <div className="section">
441
446
  <h3>What the agent gets</h3>
442
447
  <p className="muted">
@@ -448,7 +453,7 @@ export function GroupDetail() {
448
453
  <p className="muted">
449
454
  Parachute Agent doesn't impose a vault-note layout — the agent decides how to use vault access. (See{' '}
450
455
  <a
451
- href="https://github.com/ParachuteComputer/paraclaw/blob/main/docs/parachute-integration.md"
456
+ href="https://github.com/ParachuteComputer/parachute-agent/blob/main/docs/parachute-integration.md"
452
457
  target="_blank"
453
458
  rel="noreferrer"
454
459
  >
@@ -463,6 +468,126 @@ export function GroupDetail() {
463
468
  );
464
469
  }
465
470
 
471
+ const SCOPE_LABEL: Record<SecretInclusionScope, string> = {
472
+ scoped: 'scoped',
473
+ assigned: 'assigned',
474
+ global: 'global',
475
+ };
476
+
477
+ const SCOPE_HINT: Record<SecretInclusionScope, string> = {
478
+ scoped: 'Owned by this agent group — never injected into peers.',
479
+ assigned: 'Global secret routed here via an explicit assignment row.',
480
+ global: "Global secret reaching this group only because secret_mode='all'.",
481
+ };
482
+
483
+ /**
484
+ * "Secrets" panel — the env vars this group will receive at the next session
485
+ * spawn. Read-only; mirrors `resolveInjectableSecrets()` on the host. Click a
486
+ * row to jump to the SecretEditor (paraclaw#104).
487
+ *
488
+ * The list is fetched once per mount and on remount-driven reloads — there's
489
+ * no live poll because mid-life secret changes don't reach a running
490
+ * container anyway (env vars are spawn-time-only). Operators who want a
491
+ * fresher view can navigate away and back.
492
+ */
493
+ function SecretsSection({ folder, secretMode }: { folder: string; secretMode?: 'all' | 'selective' | null }) {
494
+ const [state, setState] = useState<
495
+ | { kind: 'loading' }
496
+ | { kind: 'ok'; secrets: InjectableSecretView[] }
497
+ | { kind: 'error'; message: string }
498
+ >({ kind: 'loading' });
499
+
500
+ const reload = useCallback(async () => {
501
+ setState({ kind: 'loading' });
502
+ try {
503
+ const secrets = await listGroupInjectableSecrets(folder);
504
+ setState({ kind: 'ok', secrets });
505
+ } catch (err) {
506
+ setState({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
507
+ }
508
+ }, [folder]);
509
+
510
+ useEffect(() => {
511
+ void reload();
512
+ }, [reload]);
513
+
514
+ return (
515
+ <div className="section">
516
+ <h3 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', margin: 0 }}>
517
+ Secrets
518
+ {secretMode && <span className="tag muted">mode: {secretMode}</span>}
519
+ </h3>
520
+ <p className="muted" style={{ marginTop: '0.5rem' }}>
521
+ Env vars injected into this group's containers at the next session spawn — same set the agent-runner sees.
522
+ Values never leave the encrypted DB; this list is metadata only.
523
+ </p>
524
+
525
+ {state.kind === 'loading' && (
526
+ <ul className="skeleton-list" aria-busy="true" style={{ marginTop: '0.75rem' }}>
527
+ <li className="skeleton skeleton-row" />
528
+ <li className="skeleton skeleton-row" />
529
+ </ul>
530
+ )}
531
+
532
+ {state.kind === 'error' && (
533
+ <>
534
+ <div className="error-banner" style={{ marginTop: '0.75rem' }}>
535
+ Couldn't load secrets: <code>{state.message}</code>
536
+ </div>
537
+ <div className="actions" style={{ marginTop: '0.75rem' }}>
538
+ <button onClick={reload}>Retry</button>
539
+ </div>
540
+ </>
541
+ )}
542
+
543
+ {state.kind === 'ok' && state.secrets.length === 0 && (
544
+ <div className="empty" style={{ marginTop: '0.75rem' }}>
545
+ No secrets reach this group.{' '}
546
+ {secretMode === 'selective' ? (
547
+ <>Mode is <code>selective</code> — only globals with an explicit assignment row will land here.</>
548
+ ) : (
549
+ <>Create a scoped secret or a global one with mode <code>all</code> to populate this list.</>
550
+ )}{' '}
551
+ <Link to="/secrets">Manage secrets →</Link>
552
+ </div>
553
+ )}
554
+
555
+ {state.kind === 'ok' && state.secrets.length > 0 && (
556
+ <div style={{ marginTop: '0.75rem' }}>
557
+ {state.secrets.map((s) => (
558
+ <Link
559
+ key={s.id}
560
+ to={`/secrets?edit=${encodeURIComponent(s.id)}`}
561
+ className="row clickable"
562
+ style={{
563
+ display: 'flex',
564
+ alignItems: 'center',
565
+ gap: '0.6rem',
566
+ padding: '0.4rem 0',
567
+ borderBottom: '1px solid var(--border-dim)',
568
+ textDecoration: 'none',
569
+ color: 'inherit',
570
+ }}
571
+ >
572
+ <code style={{ flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }}>
573
+ {s.name}
574
+ </code>
575
+ <span className="tag muted" title={`Kind: ${s.kind}`}>{s.kind}</span>
576
+ <span className="tag" title={SCOPE_HINT[s.scope]}>{SCOPE_LABEL[s.scope]}</span>
577
+ <span className="dim" style={{ fontSize: '0.78rem' }} title={new Date(s.updatedAt).toLocaleString()}>
578
+ {formatRelative(s.updatedAt)}
579
+ </span>
580
+ </Link>
581
+ ))}
582
+ <p className="dim" style={{ marginTop: '0.5rem' }}>
583
+ <Link to="/secrets">Manage all secrets →</Link>
584
+ </p>
585
+ </div>
586
+ )}
587
+ </div>
588
+ );
589
+ }
590
+
466
591
  function describeSource(source: AgentProviderSource | null, serverUrl: string | null): string {
467
592
  switch (source) {
468
593
  case 'claude_setup_token':
@@ -56,7 +56,7 @@ const baseDetail: api.MessagingGroupDetailView = {
56
56
  agentGroupName: 'Main agent',
57
57
  engageMode: 'mention',
58
58
  engagePattern: null,
59
- senderScope: 'all',
59
+ senderScope: 'unrestricted',
60
60
  ignoredMessagePolicy: 'drop',
61
61
  priority: 0,
62
62
  createdAt: '2026-04-20T10:00:00Z',
@@ -21,7 +21,7 @@
21
21
  * the value because the server's POST upsert wire requires `value`.
22
22
  */
23
23
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
24
- import { Link } from 'react-router-dom';
24
+ import { Link, useSearchParams } from 'react-router-dom';
25
25
  import { CredentialForm } from '../components/CredentialForm.tsx';
26
26
  import { formatRelative } from '../components/StatusDot.tsx';
27
27
  import {
@@ -76,6 +76,7 @@ export function SecretsList() {
76
76
  const [query, setQuery] = useState('');
77
77
  const [collapsed, setCollapsed] = useState<Set<SecretKind>>(new Set());
78
78
  const [editing, setEditing] = useState<SecretView | null>(null);
79
+ const [searchParams, setSearchParams] = useSearchParams();
79
80
 
80
81
  const reload = useCallback(() => setReloadKey((k) => k + 1), []);
81
82
 
@@ -95,6 +96,26 @@ export function SecretsList() {
95
96
  };
96
97
  }, [reloadKey]);
97
98
 
99
+ // Deep-link: `/secrets?edit=<id>` opens the editor for a specific secret
100
+ // on mount. Used by GroupDetail's "Secrets" panel to jump straight from a
101
+ // row to its editor (paraclaw#104). Strip the param after consuming it so
102
+ // a manual reload doesn't keep popping the same dialog open.
103
+ useEffect(() => {
104
+ if (state.kind !== 'ok') return;
105
+ const editId = searchParams.get('edit');
106
+ if (!editId) return;
107
+ const target = state.secrets.find((s) => s.id === editId);
108
+ if (target) setEditing(target);
109
+ setSearchParams(
110
+ (prev) => {
111
+ const p = new URLSearchParams(prev);
112
+ p.delete('edit');
113
+ return p;
114
+ },
115
+ { replace: true },
116
+ );
117
+ }, [state, searchParams, setSearchParams]);
118
+
98
119
  const onDelete = async (s: SecretView) => {
99
120
  if (!confirm(`Delete secret "${s.name}"? Containers using it will start failing on next session spawn.`)) return;
100
121
  setBusyId(s.id);
@@ -264,6 +264,7 @@ describe('VaultDetail — detach modal', () => {
264
264
  name: 'research',
265
265
  folder: 'research',
266
266
  agent_provider: null,
267
+ secret_mode: 'selective',
267
268
  created_at: '2026-04-20T10:00:00Z',
268
269
  vault: null,
269
270
  status: null,
@@ -302,6 +303,7 @@ describe('VaultDetail — detach modal', () => {
302
303
  name: 'research',
303
304
  folder: 'research',
304
305
  agent_provider: null,
306
+ secret_mode: 'selective',
305
307
  created_at: '2026-04-20T10:00:00Z',
306
308
  vault: null,
307
309
  status: null,