@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
@@ -207,6 +207,15 @@ export function writeSessionRouting(agentGroupId: string, sessionId: string): vo
207
207
  * ⚠ Opens and closes the DB on every call. Do not refactor to reuse a
208
208
  * long-lived connection — see the "Cross-mount visibility invariants" note
209
209
  * at the top of this file.
210
+ *
211
+ * Attachment-extraction (decode base64 → write file → swap in localPath) is
212
+ * gated on `inserted === true`. Duplicate dispatches (sender-approval replay,
213
+ * Telegram getUpdates retry, chat-sdk re-emit) carry the same `message.id`,
214
+ * so the second INSERT silently no-ops via ON CONFLICT (paraclaw#92 / #95).
215
+ * Doing the file write up-front meant a mutated replay would clobber the
216
+ * on-disk file under the original messages_in.id while the row stayed
217
+ * unchanged — divergent state with no audit trail. Reordering keeps disk
218
+ * state strictly downstream of the DB row commit (paraclaw#96).
210
219
  */
211
220
  export function writeSessionMessage(
212
221
  agentGroupId: string,
@@ -230,12 +239,12 @@ export function writeSessionMessage(
230
239
  trigger?: 0 | 1;
231
240
  },
232
241
  ): void {
233
- // Extract base64 attachment data, save to inbox, replace with file paths
234
- const content = extractAttachmentFiles(agentGroupId, sessionId, message.id, message.content);
235
-
236
242
  const db = openInboundDb(agentGroupId, sessionId);
237
243
  let inserted: boolean;
238
244
  try {
245
+ // INSERT first with the raw content (potentially carrying inline base64
246
+ // attachment data). On conflict the helper returns inserted:false and
247
+ // we exit before any filesystem work happens.
239
248
  ({ inserted } = insertMessage(db, {
240
249
  id: message.id,
241
250
  kind: message.kind,
@@ -243,11 +252,20 @@ export function writeSessionMessage(
243
252
  platformId: message.platformId ?? null,
244
253
  channelType: message.channelType ?? null,
245
254
  threadId: message.threadId ?? null,
246
- content,
255
+ content: message.content,
247
256
  processAfter: message.processAfter ?? null,
248
257
  recurrence: message.recurrence ?? null,
249
258
  trigger: message.trigger ?? 1,
250
259
  }));
260
+
261
+ if (inserted) {
262
+ // Row committed — safe to write attachment files to inbox/ and rewrite
263
+ // the row's content with the localPath form the container reads.
264
+ const extractedContent = extractAttachmentFiles(agentGroupId, sessionId, message.id, message.content);
265
+ if (extractedContent !== message.content) {
266
+ db.prepare('UPDATE messages_in SET content = ? WHERE id = ?').run(extractedContent, message.id);
267
+ }
268
+ }
251
269
  } finally {
252
270
  db.close();
253
271
  }
package/src/types.ts CHANGED
@@ -102,7 +102,10 @@ export interface UserDm {
102
102
 
103
103
  // DB vocabulary. See dbToApi* in src/web/routes/channels.ts for wire equivalents in web/ui/src/lib/api.ts.
104
104
  export type EngageMode = 'pattern' | 'mention' | 'mention-sticky';
105
- // DB vocabulary. See dbToApi* in src/web/routes/channels.ts for wire equivalents in web/ui/src/lib/api.ts.
105
+ // DB vocabulary. The DB-side `'all'` means "no sender filter"; the wire side
106
+ // uses `'allowlist' | 'unrestricted'` (paraclaw#94 — kept the two unions
107
+ // literal-disjoint so a grep-refactor can't conflate them through the
108
+ // translator).
106
109
  export type SenderScope = 'all' | 'known';
107
110
  // DB vocabulary. See dbToApi* in src/web/routes/channels.ts for wire equivalents in web/ui/src/lib/api.ts.
108
111
  export type IgnoredMessagePolicy = 'drop' | 'accumulate';
@@ -115,7 +115,10 @@ function makeRes(): MockRes & http.ServerResponse {
115
115
  }
116
116
 
117
117
  function seedFullWire(
118
- override: { mgaId?: string; engage?: Partial<Pick<MessagingGroupAgent, 'engage_mode' | 'engage_pattern'>> } = {},
118
+ override: {
119
+ mgaId?: string;
120
+ engage?: Partial<Pick<MessagingGroupAgent, 'engage_mode' | 'engage_pattern' | 'sender_scope'>>;
121
+ } = {},
119
122
  ): { mga: MessagingGroupAgent } {
120
123
  const ag = seedAgentGroup({ id: 'ag_routing', folder: 'routing', name: 'Routing agent' });
121
124
  const mg = seedMg({ id: 'mg_routing' });
@@ -125,6 +128,7 @@ function seedFullWire(
125
128
  agent_group_id: ag.id,
126
129
  engage_mode: override.engage?.engage_mode ?? 'mention',
127
130
  engage_pattern: override.engage?.engage_pattern ?? null,
131
+ sender_scope: override.engage?.sender_scope ?? 'all',
128
132
  });
129
133
  return { mga };
130
134
  }
@@ -154,7 +158,7 @@ describe('handleChannelsRoute — GET /api/channels/mga/:id', () => {
154
158
  platformId: 'telegram:111111:222222',
155
159
  engageMode: 'mention',
156
160
  engagePattern: null,
157
- senderScope: 'all',
161
+ senderScope: 'unrestricted',
158
162
  ignoredMessagePolicy: 'drop',
159
163
  priority: 0,
160
164
  });
@@ -346,6 +350,49 @@ describe('handleChannelsRoute — PATCH /api/channels/mga/:id', () => {
346
350
  });
347
351
  });
348
352
 
353
+ it("round-trips senderScope='unrestricted' to DB sender_scope='all' and back (paraclaw#94)", async () => {
354
+ // The wire vocabulary used to share the literal 'all' with the DB side
355
+ // (DB: 'all'|'known'; wire: 'allowlist'|'all') — both meant "no filter"
356
+ // but a grep-refactor of one side would have silently broken the
357
+ // translator. Renamed wire-side 'all' → 'unrestricted' to keep the
358
+ // unions literal-disjoint. Pin the round-trip so a future contributor
359
+ // who collapses the two back to one literal sees this fail.
360
+ seedFullWire({ engage: { sender_scope: 'known' } });
361
+
362
+ const res = makeRes();
363
+ await handleChannelsRoute({
364
+ pathname: '/api/channels/mga/mga_routing',
365
+ method: 'PATCH',
366
+ req: makeReq({ senderScope: 'unrestricted' }),
367
+ res,
368
+ });
369
+
370
+ expect(res.statusCode).toBe(200);
371
+ const body = res.json() as { wire: { senderScope: string } };
372
+ expect(body.wire.senderScope).toBe('unrestricted');
373
+ expect(getMessagingGroupAgent('mga_routing')!.sender_scope).toBe('all');
374
+ });
375
+
376
+ it("rejects the legacy wire literal senderScope='all' with 400 (paraclaw#94)", async () => {
377
+ // Belt-and-suspenders for the rename above: even if a client still
378
+ // sends the old wire vocabulary by mistake, validatePatchInput must
379
+ // reject it rather than silently mapping into the DB's 'all'. This is
380
+ // also the test that fails loudly if someone re-introduces 'all' to
381
+ // VALID_SENDER_SCOPES alongside 'unrestricted'.
382
+ seedFullWire();
383
+
384
+ const res = makeRes();
385
+ await handleChannelsRoute({
386
+ pathname: '/api/channels/mga/mga_routing',
387
+ method: 'PATCH',
388
+ req: makeReq({ senderScope: 'all' }),
389
+ res,
390
+ });
391
+
392
+ expect(res.statusCode).toBe(400);
393
+ expect(res.json()).toMatchObject({ error: expect.stringMatching(/invalid senderScope: all/) });
394
+ });
395
+
349
396
  it('returns 404 when the wire does not exist', async () => {
350
397
  const res = makeRes();
351
398
  await handleChannelsRoute({
@@ -1,28 +1,35 @@
1
1
  /**
2
2
  * /api/channels — channel-wire CRUD for the /channels page.
3
3
  *
4
- * A "channel wire" in the night/arch terminology = a row in the legacy
5
- * `messaging_group_agents` table joined with its messaging_groups +
6
- * agent_groups parents. We translate the storage shape (snake_case +
7
- * legacy enum names) to the camelCase API shape declared in
8
- * web/ui/src/lib/api.ts:ChannelWireView.
4
+ * The wire-shape <-> DB-shape translator now lives in
5
+ * `src/channels/api-translator.ts` (paraclaw#123) and is shared with the
6
+ * MCP `*-channel-wire` tools. See that module's docblock for the enum
7
+ * translation contract and the paraclaw#94/#122 motivation.
9
8
  *
10
- * Enum translation: night/arch's API contract uses `engageMode = mention |
11
- * pattern | all`, `senderScope = allowlist | all`, `ignoredMessagePolicy =
12
- * drop | silent`. The DB still stores the pre-rebuild values: engage_mode
13
- * = pattern | mention | mention-sticky (with engage_pattern='.' as the
14
- * sentinel for "match every message"), sender_scope = all | known,
15
- * ignored_message_policy = drop | accumulate. The translator collapses the
16
- * pattern + '.' sentinel into the API's `all` mode, lossy on the
17
- * mention-sticky distinction (rendered as `mention` to the UI). When the
18
- * DB schema migrates to the new shape, this translator becomes a no-op.
9
+ * This route file owns only the HTTP transport: routing, json/error
10
+ * helpers, the mg/:id messaging-group detail block, and the per-MGA join
11
+ * query. Validation + Db <-> Api translation come from the shared module.
19
12
  *
20
- * PATCH translates the inverse direction. DELETE is a straight pass-through
21
- * to deleteMessagingGroupAgent — the agent_destinations row created at wire
22
- * time is left in place; deleting destinations is a separate concern.
13
+ * DELETE is a straight pass-through to deleteMessagingGroupAgent — the
14
+ * agent_destinations row created at wire time is left in place; deleting
15
+ * destinations is a separate concern.
23
16
  */
24
17
  import http from 'node:http';
25
18
 
19
+ import {
20
+ ALL_MESSAGES_PATTERN_SENTINEL,
21
+ type ApiEngageMode,
22
+ type ApiIgnoredMessagePolicy,
23
+ type ApiSenderScope,
24
+ apiToDbPatch,
25
+ type ChannelWireView,
26
+ dbToApiEngage,
27
+ dbToApiIgnoredPolicy,
28
+ dbToApiSenderScope,
29
+ rowToView,
30
+ validatePatchInput,
31
+ type WireJoinRow,
32
+ } from '../../channels/api-translator.js';
26
33
  import { getAgentGroup } from '../../db/agent-groups.js';
27
34
  import { getDb } from '../../db/connection.js';
28
35
  import {
@@ -33,121 +40,7 @@ import {
33
40
  updateMessagingGroupAgent,
34
41
  } from '../../db/messaging-groups.js';
35
42
  import { log } from '../../log.js';
36
- import type {
37
- EngageMode as DbEngageMode,
38
- IgnoredMessagePolicy as DbIgnoredMessagePolicy,
39
- MessagingGroup,
40
- SenderScope as DbSenderScope,
41
- MessagingGroupAgent,
42
- UnknownSenderPolicy,
43
- } from '../../types.js';
44
-
45
- type ApiEngageMode = 'mention' | 'pattern' | 'all';
46
- type ApiSenderScope = 'allowlist' | 'all';
47
- type ApiIgnoredMessagePolicy = 'drop' | 'silent';
48
-
49
- interface ChannelWireView {
50
- id: string;
51
- channelType: string;
52
- messagingGroupId: string;
53
- platformId: string;
54
- displayName: string | null;
55
- agentGroupId: string;
56
- agentGroupFolder: string;
57
- agentGroupName: string;
58
- engageMode: ApiEngageMode;
59
- engagePattern: string | null;
60
- senderScope: ApiSenderScope;
61
- ignoredMessagePolicy: ApiIgnoredMessagePolicy;
62
- priority: number;
63
- createdAt: string;
64
- }
65
-
66
- const ALL_MESSAGES_PATTERN_SENTINEL = '.';
67
-
68
- function dbToApiEngage(mode: DbEngageMode, pattern: string | null): ApiEngageMode {
69
- if (mode === 'pattern') {
70
- return pattern === ALL_MESSAGES_PATTERN_SENTINEL ? 'all' : 'pattern';
71
- }
72
- // mention + mention-sticky both render as 'mention' on the UI today.
73
- return 'mention';
74
- }
75
-
76
- function dbToApiSenderScope(s: DbSenderScope): ApiSenderScope {
77
- return s === 'known' ? 'allowlist' : 'all';
78
- }
79
-
80
- function dbToApiIgnoredPolicy(p: DbIgnoredMessagePolicy): ApiIgnoredMessagePolicy {
81
- return p === 'accumulate' ? 'silent' : 'drop';
82
- }
83
-
84
- interface PatchInput {
85
- engageMode?: ApiEngageMode;
86
- engagePattern?: string | null;
87
- senderScope?: ApiSenderScope;
88
- ignoredMessagePolicy?: ApiIgnoredMessagePolicy;
89
- priority?: number;
90
- }
91
-
92
- interface DbPatch {
93
- engage_mode?: DbEngageMode;
94
- engage_pattern?: string | null;
95
- sender_scope?: DbSenderScope;
96
- ignored_message_policy?: DbIgnoredMessagePolicy;
97
- priority?: number;
98
- }
99
-
100
- function apiToDbPatch(input: PatchInput, current: MessagingGroupAgent): DbPatch {
101
- const out: DbPatch = {};
102
-
103
- // engageMode is paired with engagePattern: 'all' encodes as
104
- // mode='pattern' + pattern='.', which the router treats as match-every.
105
- if (input.engageMode !== undefined) {
106
- if (input.engageMode === 'all') {
107
- out.engage_mode = 'pattern';
108
- out.engage_pattern = ALL_MESSAGES_PATTERN_SENTINEL;
109
- } else if (input.engageMode === 'pattern') {
110
- out.engage_mode = 'pattern';
111
- // Pattern body comes from input.engagePattern when present; otherwise
112
- // preserve what's already on the row. validatePatchInput already
113
- // rejects bare '.' here so the next read can't silently collapse to
114
- // 'all'.
115
- if (input.engagePattern !== undefined) {
116
- out.engage_pattern = input.engagePattern;
117
- }
118
- } else if (input.engageMode === 'mention') {
119
- // Preserve mention-sticky if that's what's currently on the row;
120
- // collapsing it to plain mention here would silently change router
121
- // behavior (sticky engagement persists across replies). The UI
122
- // doesn't expose sticky → it sees `mention` for both, but a PATCH
123
- // that doesn't touch the sticky distinction shouldn't lose it.
124
- out.engage_mode = current.engage_mode === 'mention-sticky' ? 'mention-sticky' : 'mention';
125
- out.engage_pattern = null;
126
- }
127
- } else if (input.engagePattern !== undefined) {
128
- // pattern body changed without changing the mode.
129
- out.engage_pattern = input.engagePattern;
130
- }
131
-
132
- if (input.senderScope !== undefined) {
133
- out.sender_scope = input.senderScope === 'allowlist' ? 'known' : 'all';
134
- }
135
- if (input.ignoredMessagePolicy !== undefined) {
136
- out.ignored_message_policy = input.ignoredMessagePolicy === 'silent' ? 'accumulate' : 'drop';
137
- }
138
- if (input.priority !== undefined) {
139
- out.priority = input.priority;
140
- }
141
- return out;
142
- }
143
-
144
- interface WireJoinRow extends MessagingGroupAgent {
145
- mg_channel_type: string;
146
- mg_platform_id: string;
147
- mg_name: string | null;
148
- ag_folder: string;
149
- ag_name: string;
150
- }
43
+ import type { MessagingGroup, MessagingGroupAgent, UnknownSenderPolicy } from '../../types.js';
151
44
 
152
45
  function listAllWires(): ChannelWireView[] {
153
46
  const rows = getDb()
@@ -167,26 +60,6 @@ function listAllWires(): ChannelWireView[] {
167
60
  return rows.map(rowToView);
168
61
  }
169
62
 
170
- function rowToView(row: WireJoinRow): ChannelWireView {
171
- return {
172
- id: row.id,
173
- channelType: row.mg_channel_type,
174
- messagingGroupId: row.messaging_group_id,
175
- platformId: row.mg_platform_id,
176
- displayName: row.mg_name,
177
- agentGroupId: row.agent_group_id,
178
- agentGroupFolder: row.ag_folder,
179
- agentGroupName: row.ag_name,
180
- engageMode: dbToApiEngage(row.engage_mode, row.engage_pattern),
181
- engagePattern:
182
- row.engage_mode === 'pattern' && row.engage_pattern !== ALL_MESSAGES_PATTERN_SENTINEL ? row.engage_pattern : null,
183
- senderScope: dbToApiSenderScope(row.sender_scope),
184
- ignoredMessagePolicy: dbToApiIgnoredPolicy(row.ignored_message_policy),
185
- priority: row.priority,
186
- createdAt: row.created_at,
187
- };
188
- }
189
-
190
63
  function getOneWireView(id: string): ChannelWireView | null {
191
64
  const mga = getMessagingGroupAgent(id);
192
65
  if (!mga) return null;
@@ -218,57 +91,6 @@ async function readJsonBody<T>(req: http.IncomingMessage): Promise<T> {
218
91
  return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T;
219
92
  }
220
93
 
221
- const VALID_ENGAGE_MODES: ApiEngageMode[] = ['mention', 'pattern', 'all'];
222
- const VALID_SENDER_SCOPES: ApiSenderScope[] = ['allowlist', 'all'];
223
- const VALID_IGNORED_POLICIES: ApiIgnoredMessagePolicy[] = ['drop', 'silent'];
224
-
225
- function validatePatchInput(body: unknown): { ok: true; input: PatchInput } | { ok: false; reason: string } {
226
- if (!body || typeof body !== 'object') return { ok: false, reason: 'body must be an object' };
227
- const b = body as Record<string, unknown>;
228
- const out: PatchInput = {};
229
- if ('engageMode' in b) {
230
- if (!VALID_ENGAGE_MODES.includes(b.engageMode as ApiEngageMode)) {
231
- return { ok: false, reason: `invalid engageMode: ${String(b.engageMode)}` };
232
- }
233
- out.engageMode = b.engageMode as ApiEngageMode;
234
- }
235
- if ('engagePattern' in b) {
236
- if (b.engagePattern !== null && typeof b.engagePattern !== 'string') {
237
- return { ok: false, reason: 'engagePattern must be string or null' };
238
- }
239
- // Bare '.' is the wire-format sentinel for engageMode='all' — accepting
240
- // it as a literal pattern would silently round-trip back as 'all' on the
241
- // next read and lose the user's intent. Force the caller to disambiguate.
242
- if (b.engagePattern === ALL_MESSAGES_PATTERN_SENTINEL) {
243
- return {
244
- ok: false,
245
- reason:
246
- "engagePattern '.' is reserved as the 'all' sentinel — use '\\\\.' (escaped) to match a literal dot, or set engageMode to 'all'",
247
- };
248
- }
249
- out.engagePattern = b.engagePattern as string | null;
250
- }
251
- if ('senderScope' in b) {
252
- if (!VALID_SENDER_SCOPES.includes(b.senderScope as ApiSenderScope)) {
253
- return { ok: false, reason: `invalid senderScope: ${String(b.senderScope)}` };
254
- }
255
- out.senderScope = b.senderScope as ApiSenderScope;
256
- }
257
- if ('ignoredMessagePolicy' in b) {
258
- if (!VALID_IGNORED_POLICIES.includes(b.ignoredMessagePolicy as ApiIgnoredMessagePolicy)) {
259
- return { ok: false, reason: `invalid ignoredMessagePolicy: ${String(b.ignoredMessagePolicy)}` };
260
- }
261
- out.ignoredMessagePolicy = b.ignoredMessagePolicy as ApiIgnoredMessagePolicy;
262
- }
263
- if ('priority' in b) {
264
- if (typeof b.priority !== 'number' || !Number.isFinite(b.priority)) {
265
- return { ok: false, reason: 'priority must be a finite number' };
266
- }
267
- out.priority = b.priority;
268
- }
269
- return { ok: true, input: out };
270
- }
271
-
272
94
  // ─── /api/channels/mg/:id — per-MG detail + policy editor ──────────────
273
95
  //
274
96
  // Path is namespaced under `mg/` so it can't collide with `/api/channels/:mga-id`
@@ -19,7 +19,7 @@ import { getDb } from '../../db/connection.js';
19
19
  import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
20
20
  import { _setMasterKeyForTest } from '../../secrets/master-key.js';
21
21
  import { addAssignment, putSecret } from '../../secrets/index.js';
22
- import { handleSecretsRoute } from './secrets.js';
22
+ import { handleSecretsRoute, listInjectableSecretsForGroupView } from './secrets.js';
23
23
 
24
24
  beforeEach(() => {
25
25
  const db = initTestDb();
@@ -173,3 +173,48 @@ describe('GET /api/secrets/:id/stale-sessions', () => {
173
173
  expect(handled).toBe(false);
174
174
  });
175
175
  });
176
+
177
+ describe('listInjectableSecretsForGroupView (paraclaw#104)', () => {
178
+ it('projects scoped/assigned/global rows into the wire shape', () => {
179
+ seedAgentGroup('group-x', 'all');
180
+ const scopedId = putSecret('SCOPED', 'v', { agent_group_id: 'group-x' });
181
+ const assignedId = putSecret('ASSIGNED', 'v');
182
+ addAssignment(assignedId, 'group-x');
183
+ const globalId = putSecret('GLOBAL', 'v');
184
+
185
+ const view = listInjectableSecretsForGroupView('group-x');
186
+ const byName = new Map(view.map((r) => [r.name, r]));
187
+
188
+ expect(byName.get('SCOPED')).toMatchObject({
189
+ id: scopedId,
190
+ name: 'SCOPED',
191
+ kind: 'generic',
192
+ agentGroupId: 'group-x',
193
+ scope: 'scoped',
194
+ });
195
+ expect(byName.get('ASSIGNED')).toMatchObject({
196
+ id: assignedId,
197
+ agentGroupId: null,
198
+ scope: 'assigned',
199
+ });
200
+ expect(byName.get('GLOBAL')).toMatchObject({
201
+ id: globalId,
202
+ agentGroupId: null,
203
+ scope: 'global',
204
+ });
205
+
206
+ // Wire shape is camelCase, never the snake_case DB row.
207
+ for (const row of view) {
208
+ expect(row).not.toHaveProperty('agent_group_id');
209
+ expect(row).not.toHaveProperty('value_encrypted');
210
+ expect(row).toHaveProperty('createdAt');
211
+ expect(row).toHaveProperty('updatedAt');
212
+ }
213
+ });
214
+
215
+ it('returns [] for a group whose secret_mode is selective with no assignments', () => {
216
+ seedAgentGroup('empty', 'selective');
217
+ putSecret('GLOBAL', 'v');
218
+ expect(listInjectableSecretsForGroupView('empty')).toEqual([]);
219
+ });
220
+ });
@@ -14,6 +14,7 @@ import http from 'node:http';
14
14
  import { getAgentGroupSecretMode, getAgentGroupSecretModes, setAgentGroupSecretMode } from '../../db/agent-groups.js';
15
15
  import {
16
16
  type AssignedMode,
17
+ type InjectableSecretView,
17
18
  type SecretKind,
18
19
  type SecretRow,
19
20
  addAssignment,
@@ -21,6 +22,7 @@ import {
21
22
  findStaleSessionsForSecret,
22
23
  getSecretById,
23
24
  listAssignments,
25
+ listInjectableSecretsForGroup,
24
26
  listSecrets,
25
27
  putSecret,
26
28
  removeAssignment,
@@ -280,3 +282,36 @@ export async function handleSecretsRoute(ctx: SecretsRouteContext): Promise<bool
280
282
 
281
283
  return false;
282
284
  }
285
+
286
+ /**
287
+ * Per-group projection of `resolveInjectableSecrets` — metadata only, never
288
+ * decrypts. Powers the "Secrets" panel on GroupDetail (paraclaw#104). The
289
+ * caller (server.ts group-route dispatch) has already authenticated the
290
+ * request as `agent:read` and resolved `folder` → group; we just receive the
291
+ * group id.
292
+ */
293
+ export interface InjectableSecretViewWire {
294
+ id: string;
295
+ name: string;
296
+ kind: SecretKind;
297
+ agentGroupId: string | null;
298
+ scope: 'global' | 'scoped' | 'assigned';
299
+ createdAt: string;
300
+ updatedAt: string;
301
+ }
302
+
303
+ function toInjectableView(r: InjectableSecretView): InjectableSecretViewWire {
304
+ return {
305
+ id: r.id,
306
+ name: r.name,
307
+ kind: r.kind,
308
+ agentGroupId: r.agent_group_id,
309
+ scope: r.scope,
310
+ createdAt: r.created_at,
311
+ updatedAt: r.updated_at,
312
+ };
313
+ }
314
+
315
+ export function listInjectableSecretsForGroupView(agentGroupId: string): InjectableSecretViewWire[] {
316
+ return listInjectableSecretsForGroup(agentGroupId).map(toInjectableView);
317
+ }
package/src/web/server.ts CHANGED
@@ -29,7 +29,7 @@ import path from 'node:path';
29
29
  // pattern (see parachute-vault src/routing.ts).
30
30
  import pkg from '../../package.json' with { type: 'json' };
31
31
 
32
- import { CENTRAL_DB_PATH, DATA_DIR, GROUPS_DIR } from '../config.js';
32
+ import { CENTRAL_DB_PATH, DATA_DIR, GROUPS_DIR, PROJECT_ROOT } from '../config.js';
33
33
  import { openDb, type Database } from '../db/connection.js';
34
34
  import {
35
35
  attachVaultToGroup,
@@ -63,7 +63,7 @@ import { handleApprovalsRoute } from './routes/approvals.js';
63
63
  import { handleChannelsRoute } from './routes/channels.js';
64
64
  import { handleActivityRoute } from './routes/activity.js';
65
65
  import { handleOauthProvidersRoute } from './routes/oauth-providers.js';
66
- import { handleSecretsRoute } from './routes/secrets.js';
66
+ import { handleSecretsRoute, listInjectableSecretsForGroupView } from './routes/secrets.js';
67
67
  import { handleSessionsRoute } from './routes/sessions.js';
68
68
  import { handleSettingsRoute } from './routes/settings.js';
69
69
  import { handleAgentProviderRoute, handleGroupAgentProviderRoute } from './routes/agent-provider.js';
@@ -81,7 +81,6 @@ import { getSecret, putSecret } from '../secrets/index.js';
81
81
  import { channelTokenSecretName } from '../startup-bootstrap.js';
82
82
  import { readEnvWithLegacy } from '../env.js';
83
83
 
84
- const PROJECT_ROOT = process.cwd();
85
84
  const UI_DIST = path.resolve(PROJECT_ROOT, 'web/ui/dist');
86
85
  // Canonical Parachute slot per parachute-patterns/patterns/canonical-ports.md
87
86
  // (1944, claimed for parachute-agent 2026-04-27 via parachute-hub#…). Override
@@ -105,6 +104,11 @@ interface AgentGroupRow {
105
104
  name: string;
106
105
  folder: string;
107
106
  agent_provider: string | null;
107
+ // Per-group injection policy for secrets — the GroupDetail "Secrets" panel
108
+ // (paraclaw#104) renders this so an empty list under `selective` reads as
109
+ // "by design" rather than "broken". Already a column on agent_groups
110
+ // (migration 023); just surface it on the wire.
111
+ secret_mode: 'all' | 'selective';
108
112
  created_at: string;
109
113
  }
110
114
 
@@ -126,7 +130,9 @@ function listAgentGroups(): AgentGroupView[] {
126
130
  const db = getReadonlyDb();
127
131
  try {
128
132
  const rows = db
129
- .prepare('SELECT id, name, folder, agent_provider, created_at FROM agent_groups ORDER BY created_at DESC')
133
+ .prepare(
134
+ 'SELECT id, name, folder, agent_provider, secret_mode, created_at FROM agent_groups ORDER BY created_at DESC',
135
+ )
130
136
  .all() as AgentGroupRow[];
131
137
  return rows.map((r) => ({
132
138
  ...r,
@@ -142,7 +148,7 @@ function getAgentGroup(folder: string): AgentGroupView | null {
142
148
  const db = getReadonlyDb();
143
149
  try {
144
150
  const row = db
145
- .prepare('SELECT id, name, folder, agent_provider, created_at FROM agent_groups WHERE folder = ?')
151
+ .prepare('SELECT id, name, folder, agent_provider, secret_mode, created_at FROM agent_groups WHERE folder = ?')
146
152
  .get(folder) as AgentGroupRow | undefined;
147
153
  if (!row) return null;
148
154
  return {
@@ -608,16 +614,14 @@ async function handleApi(
608
614
  const folder = decodeURIComponent(groupRoute[1]);
609
615
  const sub = groupRoute[2] ?? '';
610
616
 
611
- // Reads at the group root + the agent-provider subroute go through
612
- // agent:read; writes default to agent:write; agent-provider writes
613
- // (paraclaw#86) bump to agent:admin since they store API keys.
617
+ // Reads at the group root + the agent-provider subroute + the
618
+ // injectable-secrets panel (paraclaw#104) go through agent:read; writes
619
+ // default to agent:write; agent-provider writes (paraclaw#86) bump to
620
+ // agent:admin since they store API keys.
614
621
  const isAgentProviderSub = sub === '/agent-provider';
622
+ const isReadSub = sub === '' || isAgentProviderSub || sub === '/secrets';
615
623
  const requiredScope: AgentScope =
616
- method === 'GET' && (sub === '' || isAgentProviderSub)
617
- ? SCOPE_AGENT_READ
618
- : isAgentProviderSub
619
- ? SCOPE_AGENT_ADMIN
620
- : SCOPE_AGENT_WRITE;
624
+ method === 'GET' && isReadSub ? SCOPE_AGENT_READ : isAgentProviderSub ? SCOPE_AGENT_ADMIN : SCOPE_AGENT_WRITE;
621
625
  // Authenticate once; capture sub so the agent-provider sub-route's
622
626
  // audit log doesn't have to re-decode the JWT.
623
627
  const auth = await authenticate(req.headers.authorization, requiredScope);
@@ -890,6 +894,19 @@ async function handleApi(
890
894
  return;
891
895
  }
892
896
 
897
+ // Read-only mirror of resolveInjectableSecrets() for the GroupDetail
898
+ // "Secrets" panel — what env vars this group will see at next session
899
+ // spawn, with scope badges (paraclaw#104). Metadata only; values stay
900
+ // encrypted at rest and only decrypt at container spawn time.
901
+ if (sub === '/secrets' && method === 'GET') {
902
+ try {
903
+ json(res, 200, { secrets: listInjectableSecretsForGroupView(group.id) });
904
+ } catch (err) {
905
+ error(res, 500, err instanceof Error ? err.message : String(err));
906
+ }
907
+ return;
908
+ }
909
+
893
910
  error(res, 405, `method not allowed: ${method} ${pathname}`);
894
911
  return;
895
912
  }
@@ -991,6 +1008,10 @@ export function startWebServer(): http.Server {
991
1008
  version: SERVICE_VERSION,
992
1009
  displayName: 'Parachute Agent',
993
1010
  tagline: 'Manage your Parachute agent groups + vault attachments.',
1011
+ // Lets hub resolve `parachute restart agent` back to the checkout
1012
+ // it should drive without a vendored fallback (paraclaw#115,
1013
+ // third-party-module hook from parachute-hub#84).
1014
+ installDir: PROJECT_ROOT,
994
1015
  });
995
1016
  } catch (err) {
996
1017
  log.warn('Skipped services manifest update', {