@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
package/src/session-manager.ts
CHANGED
|
@@ -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.
|
|
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: {
|
|
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: '
|
|
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
|
-
*
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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(
|
|
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
|
|
612
|
-
//
|
|
613
|
-
//
|
|
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' &&
|
|
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', {
|