@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,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/
|
|
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: '
|
|
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,
|