@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +675 -21
  3. package/LICENSE-NANOCLAW-MIT +21 -0
  4. package/README.md +8 -1
  5. package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +1 -1
  6. package/package.json +2 -1
  7. package/scripts/init-cli-agent.ts +2 -1
  8. package/scripts/init-first-agent.ts +2 -1
  9. package/scripts/seed-discord.ts +2 -1
  10. package/src/channels/api-translator.test.ts +306 -0
  11. package/src/channels/api-translator.ts +214 -0
  12. package/src/config.ts +23 -3
  13. package/src/container-runtime.test.ts +101 -1
  14. package/src/container-runtime.ts +76 -1
  15. package/src/db/connection.migrate.test.ts +35 -2
  16. package/src/db/connection.ts +40 -5
  17. package/src/index.ts +6 -1
  18. package/src/mcp/tools/channels.test.ts +126 -0
  19. package/src/mcp/tools/channels.ts +33 -98
  20. package/src/modules/mount-security/expand-path.test.ts +82 -0
  21. package/src/modules/mount-security/index.ts +21 -10
  22. package/src/modules/permissions/sender-approval.test.ts +171 -0
  23. package/src/secrets/index.ts +127 -21
  24. package/src/secrets/secrets.test.ts +301 -4
  25. package/src/session-manager.attachments.test.ts +171 -0
  26. package/src/session-manager.dup-skip.test.ts +173 -0
  27. package/src/session-manager.ts +22 -4
  28. package/src/types.ts +4 -1
  29. package/src/web/routes/channels-mga-detail.test.ts +49 -2
  30. package/src/web/routes/channels.ts +25 -203
  31. package/src/web/routes/secrets.test.ts +46 -1
  32. package/src/web/routes/secrets.ts +35 -0
  33. package/src/web/server.ts +34 -13
  34. package/src/web/services-manifest.test.ts +37 -9
  35. package/src/web/services-manifest.ts +14 -9
  36. package/web/ui/index.html +2 -2
  37. package/web/ui/src/App.tsx +1 -1
  38. package/web/ui/src/lib/api.test.ts +2 -2
  39. package/web/ui/src/lib/api.ts +40 -2
  40. package/web/ui/src/lib/auth.test.ts +214 -1
  41. package/web/ui/src/lib/auth.ts +79 -22
  42. package/web/ui/src/routes/ChannelWireDetail.test.tsx +2 -2
  43. package/web/ui/src/routes/ChannelWireDetail.tsx +1 -1
  44. package/web/ui/src/routes/GroupDetail.test.tsx +206 -0
  45. package/web/ui/src/routes/GroupDetail.tsx +126 -1
  46. package/web/ui/src/routes/MessagingGroupDetail.test.tsx +1 -1
  47. package/web/ui/src/routes/SecretsList.tsx +22 -1
  48. package/web/ui/src/routes/VaultDetail.test.tsx +2 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gavriel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -187,4 +187,11 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes.
187
187
 
188
188
  ## License
189
189
 
190
- MIT
190
+ parachute-agent is licensed under the GNU Affero General Public License v3.0
191
+ ([LICENSE](./LICENSE)).
192
+
193
+ It is a derivative of [NanoClaw](https://github.com/qwibitai/nanoclaw) (MIT —
194
+ see [LICENSE-NANOCLAW-MIT](./LICENSE-NANOCLAW-MIT) for the original copyright
195
+ notice). Substantial modifications and the combined work are AGPL-3.0; the
196
+ original NanoClaw code remains MIT-licensed and can be obtained from the
197
+ upstream project.
@@ -23,7 +23,7 @@ This doc proposes how to fix all three together. No impl until Aaron reads it.
23
23
  | `sender_scope` | `all` \| `known` | `all` = no-op; `known` = `canAccessAgentGroup(userId, agent_group_id)` must allow. Enforced via the `senderScopeGate` hook the permissions module registers. | `src/modules/permissions/index.ts:175-183` |
24
24
  | `ignored_message_policy` | `drop` \| `accumulate` | Branch on the *non-engaging* path: `drop` = silently skip; `accumulate` = still write the inbound row to the agent's session DB with `trigger=0`, so context is available next time it does engage | `src/router.ts:355-358` |
25
25
 
26
- Note the API surface (`src/web/routes/channels.ts:8-22`) uses different enum names that translate at the route boundary — `engageMode='all'` collapses to DB `engage_mode='pattern'` + `engage_pattern='.'`; `senderScope='allowlist'` ↔ `sender_scope='known'`; `ignoredMessagePolicy='silent'` ↔ `ignored_message_policy='accumulate'`. The UI sees the API names. The DB keeps the original ones. The translator is lossy on the `mention` ↔ `mention-sticky` distinction (renders both as `mention`); the `apiToDbPatch` at `src/web/routes/channels.ts:97-127` carefully preserves sticky on round-trip.
26
+ Note the API surface (`src/web/routes/channels.ts:8-22`) uses different enum names that translate at the route boundary — `engageMode='all'` collapses to DB `engage_mode='pattern'` + `engage_pattern='.'`; `senderScope='allowlist'` ↔ `sender_scope='known'` and `senderScope='unrestricted'` ↔ `sender_scope='all'` (paraclaw#94 renamed wire-side `'all'` → `'unrestricted'` so the two `SenderScope` unions are literal-disjoint); `ignoredMessagePolicy='silent'` ↔ `ignored_message_policy='accumulate'`. The UI sees the API names. The DB keeps the original ones. The translator is lossy on the `mention` ↔ `mention-sticky` distinction (renders both as `mention`); the `apiToDbPatch` at `src/web/routes/channels.ts:97-127` carefully preserves sticky on round-trip.
27
27
 
28
28
  ### 1b. The MG-level knob
29
29
 
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@openparachute/agent",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Parachute Agent — per-session containerized AI agent companion.",
5
+ "license": "AGPL-3.0",
5
6
  "type": "module",
6
7
  "packageManager": "pnpm@10.33.0",
7
8
  "main": "src/index.ts",
@@ -21,7 +21,7 @@ import path from 'path';
21
21
 
22
22
  import { CENTRAL_DB_PATH } from '../src/config.js';
23
23
  import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
24
- import { initDb, migrateCentralDbLocation } from '../src/db/connection.js';
24
+ import { initDb, migrateCentralDbLocation, migrateMasterKeyLocation } from '../src/db/connection.js';
25
25
  import {
26
26
  createMessagingGroup,
27
27
  createMessagingGroupAgent,
@@ -78,6 +78,7 @@ async function main(): Promise<void> {
78
78
  const args = parseArgs(process.argv.slice(2));
79
79
 
80
80
  migrateCentralDbLocation();
81
+ migrateMasterKeyLocation();
81
82
  const db = initDb(CENTRAL_DB_PATH);
82
83
  runMigrations(db);
83
84
 
@@ -35,7 +35,7 @@ import path from 'path';
35
35
 
36
36
  import { CENTRAL_DB_PATH, DATA_DIR } from '../src/config.js';
37
37
  import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
38
- import { initDb, migrateCentralDbLocation } from '../src/db/connection.js';
38
+ import { initDb, migrateCentralDbLocation, migrateMasterKeyLocation } from '../src/db/connection.js';
39
39
  import {
40
40
  createMessagingGroup,
41
41
  createMessagingGroupAgent,
@@ -170,6 +170,7 @@ async function main(): Promise<void> {
170
170
  const args = parseArgs(process.argv.slice(2));
171
171
 
172
172
  migrateCentralDbLocation();
173
+ migrateMasterKeyLocation();
173
174
  const db = initDb(CENTRAL_DB_PATH);
174
175
  runMigrations(db); // idempotent
175
176
 
@@ -4,7 +4,7 @@
4
4
  * Usage: pnpm exec tsx scripts/seed-discord.ts
5
5
  */
6
6
  import { CENTRAL_DB_PATH } from '../src/config.js';
7
- import { initDb, migrateCentralDbLocation } from '../src/db/connection.js';
7
+ import { initDb, migrateCentralDbLocation, migrateMasterKeyLocation } from '../src/db/connection.js';
8
8
  import { runMigrations } from '../src/db/migrations/index.js';
9
9
  import { createAgentGroup, getAgentGroup } from '../src/db/agent-groups.js';
10
10
  import {
@@ -14,6 +14,7 @@ import {
14
14
  } from '../src/db/messaging-groups.js';
15
15
 
16
16
  migrateCentralDbLocation();
17
+ migrateMasterKeyLocation();
17
18
  const db = initDb(CENTRAL_DB_PATH);
18
19
  runMigrations(db);
19
20
 
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Round-trip + validator coverage for the shared channel-wire translator
3
+ * (paraclaw#123). The HTTP and MCP surfaces both depend on this module to
4
+ * keep the wire ↔ DB enums in lockstep — paraclaw#94/#122 was the drift
5
+ * incident that motivated extracting these tests behind one file.
6
+ */
7
+ import { describe, it, expect } from 'vitest';
8
+
9
+ import type { MessagingGroupAgent } from '../types.js';
10
+ import {
11
+ ALL_MESSAGES_PATTERN_SENTINEL,
12
+ apiToDbPatch,
13
+ dbToApiEngage,
14
+ dbToApiIgnoredPolicy,
15
+ dbToApiSenderScope,
16
+ rowToView,
17
+ validatePatchInput,
18
+ type WireJoinRow,
19
+ } from './api-translator.js';
20
+
21
+ function baseRow(overrides: Partial<WireJoinRow> = {}): WireJoinRow {
22
+ return {
23
+ id: 'mga-1',
24
+ messaging_group_id: 'mg-1',
25
+ agent_group_id: 'ag-1',
26
+ engage_mode: 'mention',
27
+ engage_pattern: null,
28
+ sender_scope: 'all',
29
+ ignored_message_policy: 'drop',
30
+ session_mode: 'shared',
31
+ priority: 0,
32
+ created_at: '2026-05-05T00:00:00Z',
33
+ mg_channel_type: 'discord',
34
+ mg_platform_id: 'guild-123',
35
+ mg_name: 'general',
36
+ ag_folder: 'research',
37
+ ag_name: 'Research',
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function baseCurrent(overrides: Partial<MessagingGroupAgent> = {}): MessagingGroupAgent {
43
+ return {
44
+ id: 'mga-1',
45
+ messaging_group_id: 'mg-1',
46
+ agent_group_id: 'ag-1',
47
+ engage_mode: 'mention',
48
+ engage_pattern: null,
49
+ sender_scope: 'all',
50
+ ignored_message_policy: 'drop',
51
+ session_mode: 'shared',
52
+ priority: 0,
53
+ created_at: '2026-05-05T00:00:00Z',
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ describe('dbToApiEngage', () => {
59
+ it('mention + null → mention', () => {
60
+ expect(dbToApiEngage('mention', null)).toBe('mention');
61
+ });
62
+
63
+ it('mention-sticky collapses to mention on the wire', () => {
64
+ // The wire deliberately doesn't expose sticky — see api-translator.ts
65
+ // docblock. apiToDbPatch's mention-sticky preservation is what keeps
66
+ // sticky-mode rows from silently flattening on PATCHes that don't
67
+ // touch the engagement fields.
68
+ expect(dbToApiEngage('mention-sticky', null)).toBe('mention');
69
+ });
70
+
71
+ it("pattern + '.' sentinel → all", () => {
72
+ expect(dbToApiEngage('pattern', ALL_MESSAGES_PATTERN_SENTINEL)).toBe('all');
73
+ });
74
+
75
+ it('pattern + real regex body → pattern', () => {
76
+ expect(dbToApiEngage('pattern', '\\bdeploy\\b')).toBe('pattern');
77
+ });
78
+
79
+ it('pattern + null → pattern (defensive — schema disallows but translator must not crash)', () => {
80
+ expect(dbToApiEngage('pattern', null)).toBe('pattern');
81
+ });
82
+ });
83
+
84
+ describe('dbToApiSenderScope', () => {
85
+ it("DB 'known' → wire 'allowlist'", () => {
86
+ expect(dbToApiSenderScope('known')).toBe('allowlist');
87
+ });
88
+
89
+ it("DB 'all' → wire 'unrestricted' (paraclaw#94 — disjoint literals)", () => {
90
+ expect(dbToApiSenderScope('all')).toBe('unrestricted');
91
+ });
92
+ });
93
+
94
+ describe('dbToApiIgnoredPolicy', () => {
95
+ it("DB 'accumulate' → wire 'silent'", () => {
96
+ expect(dbToApiIgnoredPolicy('accumulate')).toBe('silent');
97
+ });
98
+
99
+ it("DB 'drop' → wire 'drop'", () => {
100
+ expect(dbToApiIgnoredPolicy('drop')).toBe('drop');
101
+ });
102
+ });
103
+
104
+ describe('rowToView', () => {
105
+ it('projects every join column onto the wire view', () => {
106
+ const view = rowToView(
107
+ baseRow({
108
+ engage_mode: 'pattern',
109
+ engage_pattern: '\\bping\\b',
110
+ sender_scope: 'known',
111
+ ignored_message_policy: 'accumulate',
112
+ priority: 5,
113
+ }),
114
+ );
115
+ expect(view).toEqual({
116
+ id: 'mga-1',
117
+ channelType: 'discord',
118
+ messagingGroupId: 'mg-1',
119
+ platformId: 'guild-123',
120
+ displayName: 'general',
121
+ agentGroupId: 'ag-1',
122
+ agentGroupFolder: 'research',
123
+ agentGroupName: 'Research',
124
+ engageMode: 'pattern',
125
+ engagePattern: '\\bping\\b',
126
+ senderScope: 'allowlist',
127
+ ignoredMessagePolicy: 'silent',
128
+ priority: 5,
129
+ createdAt: '2026-05-05T00:00:00Z',
130
+ });
131
+ });
132
+
133
+ it("collapses pattern + '.' to engageMode='all' and nulls engagePattern on the wire", () => {
134
+ const view = rowToView(baseRow({ engage_mode: 'pattern', engage_pattern: ALL_MESSAGES_PATTERN_SENTINEL }));
135
+ expect(view.engageMode).toBe('all');
136
+ expect(view.engagePattern).toBeNull();
137
+ });
138
+
139
+ it('mention mode never leaks the engage_pattern column to the wire', () => {
140
+ // In practice the schema keeps engage_pattern null for mention rows, but
141
+ // the projection must not surface stale pattern bodies if a row drifts.
142
+ const view = rowToView(baseRow({ engage_mode: 'mention', engage_pattern: 'leftover' }));
143
+ expect(view.engageMode).toBe('mention');
144
+ expect(view.engagePattern).toBeNull();
145
+ });
146
+ });
147
+
148
+ describe('apiToDbPatch — engageMode encoding', () => {
149
+ it("engageMode='all' → mode=pattern + pattern='.'", () => {
150
+ const out = apiToDbPatch({ engageMode: 'all' }, baseCurrent());
151
+ expect(out.engage_mode).toBe('pattern');
152
+ expect(out.engage_pattern).toBe(ALL_MESSAGES_PATTERN_SENTINEL);
153
+ });
154
+
155
+ it('engageMode=pattern + engagePattern → both written', () => {
156
+ const out = apiToDbPatch({ engageMode: 'pattern', engagePattern: '\\bdeploy\\b' }, baseCurrent());
157
+ expect(out.engage_mode).toBe('pattern');
158
+ expect(out.engage_pattern).toBe('\\bdeploy\\b');
159
+ });
160
+
161
+ it('engageMode=pattern without engagePattern → only mode set, pattern preserved on the row', () => {
162
+ // The PATCH-shape semantic: an undefined field means "leave it alone."
163
+ // The DB-side row already has the prior pattern; we don't overwrite it.
164
+ const out = apiToDbPatch({ engageMode: 'pattern' }, baseCurrent({ engage_pattern: 'old' }));
165
+ expect(out.engage_mode).toBe('pattern');
166
+ expect(out).not.toHaveProperty('engage_pattern');
167
+ });
168
+
169
+ it("engageMode='mention' nulls engage_pattern", () => {
170
+ const out = apiToDbPatch({ engageMode: 'mention' }, baseCurrent());
171
+ expect(out.engage_mode).toBe('mention');
172
+ expect(out.engage_pattern).toBeNull();
173
+ });
174
+
175
+ it("engageMode='mention' preserves mention-sticky when current row is sticky", () => {
176
+ // Wire doesn't expose sticky → both mention + mention-sticky show as
177
+ // 'mention' on the read side. A PATCH that flips back to 'mention' on
178
+ // the wire shouldn't silently demote the sticky bit on the row.
179
+ const out = apiToDbPatch({ engageMode: 'mention' }, baseCurrent({ engage_mode: 'mention-sticky' }));
180
+ expect(out.engage_mode).toBe('mention-sticky');
181
+ expect(out.engage_pattern).toBeNull();
182
+ });
183
+
184
+ it('engagePattern alone (no engageMode) → only pattern body changes', () => {
185
+ const out = apiToDbPatch({ engagePattern: '\\bnew\\b' }, baseCurrent({ engage_mode: 'pattern' }));
186
+ expect(out).not.toHaveProperty('engage_mode');
187
+ expect(out.engage_pattern).toBe('\\bnew\\b');
188
+ });
189
+ });
190
+
191
+ describe('apiToDbPatch — sender scope and ignored policy', () => {
192
+ it("senderScope 'allowlist' → DB 'known'", () => {
193
+ expect(apiToDbPatch({ senderScope: 'allowlist' }, baseCurrent()).sender_scope).toBe('known');
194
+ });
195
+
196
+ it("senderScope 'unrestricted' → DB 'all'", () => {
197
+ expect(apiToDbPatch({ senderScope: 'unrestricted' }, baseCurrent()).sender_scope).toBe('all');
198
+ });
199
+
200
+ it("ignoredMessagePolicy 'silent' → DB 'accumulate'", () => {
201
+ expect(apiToDbPatch({ ignoredMessagePolicy: 'silent' }, baseCurrent()).ignored_message_policy).toBe('accumulate');
202
+ });
203
+
204
+ it("ignoredMessagePolicy 'drop' → DB 'drop'", () => {
205
+ expect(apiToDbPatch({ ignoredMessagePolicy: 'drop' }, baseCurrent()).ignored_message_policy).toBe('drop');
206
+ });
207
+
208
+ it('priority passes through unchanged', () => {
209
+ expect(apiToDbPatch({ priority: 7 }, baseCurrent()).priority).toBe(7);
210
+ });
211
+
212
+ it('empty input → empty patch', () => {
213
+ expect(apiToDbPatch({}, baseCurrent())).toEqual({});
214
+ });
215
+ });
216
+
217
+ describe('validatePatchInput', () => {
218
+ it('rejects non-object body', () => {
219
+ expect(validatePatchInput(null)).toEqual({ ok: false, reason: 'body must be an object' });
220
+ expect(validatePatchInput('string')).toEqual({ ok: false, reason: 'body must be an object' });
221
+ expect(validatePatchInput(42)).toEqual({ ok: false, reason: 'body must be an object' });
222
+ });
223
+
224
+ it('passes a fully-populated valid body', () => {
225
+ const result = validatePatchInput({
226
+ engageMode: 'pattern',
227
+ engagePattern: '\\bping\\b',
228
+ senderScope: 'allowlist',
229
+ ignoredMessagePolicy: 'silent',
230
+ priority: 3,
231
+ });
232
+ expect(result).toEqual({
233
+ ok: true,
234
+ input: {
235
+ engageMode: 'pattern',
236
+ engagePattern: '\\bping\\b',
237
+ senderScope: 'allowlist',
238
+ ignoredMessagePolicy: 'silent',
239
+ priority: 3,
240
+ },
241
+ });
242
+ });
243
+
244
+ it("rejects legacy wire-side senderScope='all' (paraclaw#94 rename)", () => {
245
+ // Pre-paraclaw#94 the wire used 'all' on both axes; the rename to
246
+ // 'unrestricted' was specifically to make a grep-refactor unable to
247
+ // conflate the wire and DB unions. The validator must now reject the
248
+ // old literal.
249
+ const result = validatePatchInput({ senderScope: 'all' });
250
+ expect(result.ok).toBe(false);
251
+ if (!result.ok) expect(result.reason).toContain('senderScope');
252
+ });
253
+
254
+ it("rejects legacy ignoredMessagePolicy='accumulate' on the wire", () => {
255
+ // 'accumulate' is the DB-side spelling. The wire spelling is 'silent'.
256
+ const result = validatePatchInput({ ignoredMessagePolicy: 'accumulate' });
257
+ expect(result.ok).toBe(false);
258
+ if (!result.ok) expect(result.reason).toContain('ignoredMessagePolicy');
259
+ });
260
+
261
+ it("rejects engageMode='mention-sticky' (DB-only literal)", () => {
262
+ const result = validatePatchInput({ engageMode: 'mention-sticky' });
263
+ expect(result.ok).toBe(false);
264
+ if (!result.ok) expect(result.reason).toContain('engageMode');
265
+ });
266
+
267
+ it("rejects bare '.' as engagePattern (sentinel reservation)", () => {
268
+ // Storing '.' would silently round-trip back as engageMode='all' on the
269
+ // next read. The fix landed in paraclaw#122 — keep the regression here.
270
+ const result = validatePatchInput({ engagePattern: ALL_MESSAGES_PATTERN_SENTINEL });
271
+ expect(result.ok).toBe(false);
272
+ if (!result.ok) expect(result.reason).toMatch(/sentinel/);
273
+ });
274
+
275
+ it("accepts escaped literal-dot pattern '\\\\.'", () => {
276
+ // The error message above tells the caller to escape; that escaped
277
+ // form must round-trip cleanly.
278
+ const result = validatePatchInput({ engagePattern: '\\.' });
279
+ expect(result.ok).toBe(true);
280
+ if (result.ok) expect(result.input.engagePattern).toBe('\\.');
281
+ });
282
+
283
+ it('accepts engagePattern=null (clear-the-pattern PATCH)', () => {
284
+ const result = validatePatchInput({ engagePattern: null });
285
+ expect(result.ok).toBe(true);
286
+ if (result.ok) expect(result.input.engagePattern).toBeNull();
287
+ });
288
+
289
+ it('rejects non-string non-null engagePattern', () => {
290
+ expect(validatePatchInput({ engagePattern: 5 }).ok).toBe(false);
291
+ expect(validatePatchInput({ engagePattern: {} }).ok).toBe(false);
292
+ });
293
+
294
+ it('rejects non-finite priority', () => {
295
+ expect(validatePatchInput({ priority: Infinity }).ok).toBe(false);
296
+ expect(validatePatchInput({ priority: NaN }).ok).toBe(false);
297
+ expect(validatePatchInput({ priority: '5' }).ok).toBe(false);
298
+ });
299
+
300
+ it('drops unknown keys silently (forward-compat)', () => {
301
+ // The validator only inspects fields it knows; unknown keys aren't an
302
+ // error, they just don't make it into the typed output.
303
+ const result = validatePatchInput({ engageMode: 'mention', futureField: 'nope' });
304
+ expect(result).toEqual({ ok: true, input: { engageMode: 'mention' } });
305
+ });
306
+ });
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Shared translator between the storage shape (`messaging_group_agents` row,
3
+ * snake_case + legacy enum names) and the API contract that both `/api/channels`
4
+ * and the MCP `*-channel-wire` tools speak.
5
+ *
6
+ * Background. Both surfaces used to maintain their own copy of these types,
7
+ * constants, translators, and the patch validator. The duplication was a
8
+ * structural drift hazard: paraclaw#94 / PR #122 surfaced exactly that class
9
+ * — the rename of wire-side `'all'` → `'unrestricted'` initially landed only
10
+ * the HTTP-side validator and missed the MCP-side silent-no-op. Extracting
11
+ * here makes the drift class structurally impossible: a future enum change
12
+ * touches one file, both surfaces pick it up. paraclaw#123.
13
+ *
14
+ * API contract: engageMode = mention | pattern | all, senderScope = allowlist
15
+ * | unrestricted, ignoredMessagePolicy = drop | silent.
16
+ *
17
+ * DB shape (still pre-rebuild): engage_mode = mention | pattern |
18
+ * mention-sticky (with engage_pattern='.' as the "match every message"
19
+ * sentinel), sender_scope = all | known, ignored_message_policy = drop |
20
+ * accumulate. The translator collapses pattern + '.' into the API's `all`,
21
+ * lossy on mention-sticky (rendered as `mention` to the wire).
22
+ *
23
+ * The validator returns a discriminated result rather than throwing so each
24
+ * caller can pick its own error idiom: HTTP wraps `{ ok: false }` into a
25
+ * 400 + JSON error; MCP throws on `{ ok: false }`.
26
+ */
27
+ import type {
28
+ EngageMode as DbEngageMode,
29
+ IgnoredMessagePolicy as DbIgnoredMessagePolicy,
30
+ MessagingGroupAgent,
31
+ SenderScope as DbSenderScope,
32
+ } from '../types.js';
33
+
34
+ export type ApiEngageMode = 'mention' | 'pattern' | 'all';
35
+ export type ApiSenderScope = 'allowlist' | 'unrestricted';
36
+ export type ApiIgnoredMessagePolicy = 'drop' | 'silent';
37
+
38
+ export const ALL_MESSAGES_PATTERN_SENTINEL = '.';
39
+
40
+ export const VALID_API_ENGAGE_MODES: ApiEngageMode[] = ['mention', 'pattern', 'all'];
41
+ export const VALID_API_SENDER_SCOPES: ApiSenderScope[] = ['allowlist', 'unrestricted'];
42
+ export const VALID_API_IGNORED_POLICIES: ApiIgnoredMessagePolicy[] = ['drop', 'silent'];
43
+
44
+ export interface ChannelWireView {
45
+ id: string;
46
+ channelType: string;
47
+ messagingGroupId: string;
48
+ platformId: string;
49
+ displayName: string | null;
50
+ agentGroupId: string;
51
+ agentGroupFolder: string;
52
+ agentGroupName: string;
53
+ engageMode: ApiEngageMode;
54
+ engagePattern: string | null;
55
+ senderScope: ApiSenderScope;
56
+ ignoredMessagePolicy: ApiIgnoredMessagePolicy;
57
+ priority: number;
58
+ createdAt: string;
59
+ }
60
+
61
+ export interface WireJoinRow extends MessagingGroupAgent {
62
+ mg_channel_type: string;
63
+ mg_platform_id: string;
64
+ mg_name: string | null;
65
+ ag_folder: string;
66
+ ag_name: string;
67
+ }
68
+
69
+ export interface PatchInput {
70
+ engageMode?: ApiEngageMode;
71
+ engagePattern?: string | null;
72
+ senderScope?: ApiSenderScope;
73
+ ignoredMessagePolicy?: ApiIgnoredMessagePolicy;
74
+ priority?: number;
75
+ }
76
+
77
+ export interface DbPatch {
78
+ engage_mode?: DbEngageMode;
79
+ engage_pattern?: string | null;
80
+ sender_scope?: DbSenderScope;
81
+ ignored_message_policy?: DbIgnoredMessagePolicy;
82
+ priority?: number;
83
+ }
84
+
85
+ export function dbToApiEngage(mode: DbEngageMode, pattern: string | null): ApiEngageMode {
86
+ if (mode === 'pattern') {
87
+ return pattern === ALL_MESSAGES_PATTERN_SENTINEL ? 'all' : 'pattern';
88
+ }
89
+ // mention + mention-sticky both render as 'mention' on the wire today.
90
+ return 'mention';
91
+ }
92
+
93
+ export function dbToApiSenderScope(s: DbSenderScope): ApiSenderScope {
94
+ return s === 'known' ? 'allowlist' : 'unrestricted';
95
+ }
96
+
97
+ export function dbToApiIgnoredPolicy(p: DbIgnoredMessagePolicy): ApiIgnoredMessagePolicy {
98
+ return p === 'accumulate' ? 'silent' : 'drop';
99
+ }
100
+
101
+ export function rowToView(row: WireJoinRow): ChannelWireView {
102
+ return {
103
+ id: row.id,
104
+ channelType: row.mg_channel_type,
105
+ messagingGroupId: row.messaging_group_id,
106
+ platformId: row.mg_platform_id,
107
+ displayName: row.mg_name,
108
+ agentGroupId: row.agent_group_id,
109
+ agentGroupFolder: row.ag_folder,
110
+ agentGroupName: row.ag_name,
111
+ engageMode: dbToApiEngage(row.engage_mode, row.engage_pattern),
112
+ engagePattern:
113
+ row.engage_mode === 'pattern' && row.engage_pattern !== ALL_MESSAGES_PATTERN_SENTINEL ? row.engage_pattern : null,
114
+ senderScope: dbToApiSenderScope(row.sender_scope),
115
+ ignoredMessagePolicy: dbToApiIgnoredPolicy(row.ignored_message_policy),
116
+ priority: row.priority,
117
+ createdAt: row.created_at,
118
+ };
119
+ }
120
+
121
+ export function apiToDbPatch(input: PatchInput, current: MessagingGroupAgent): DbPatch {
122
+ const out: DbPatch = {};
123
+
124
+ // engageMode is paired with engagePattern: 'all' encodes as
125
+ // mode='pattern' + pattern='.', which the router treats as match-every.
126
+ if (input.engageMode !== undefined) {
127
+ if (input.engageMode === 'all') {
128
+ out.engage_mode = 'pattern';
129
+ out.engage_pattern = ALL_MESSAGES_PATTERN_SENTINEL;
130
+ } else if (input.engageMode === 'pattern') {
131
+ out.engage_mode = 'pattern';
132
+ // Pattern body comes from input.engagePattern when present; otherwise
133
+ // preserve what's already on the row. validatePatchInput already
134
+ // rejects bare '.' here so the next read can't silently collapse to
135
+ // 'all'.
136
+ if (input.engagePattern !== undefined) {
137
+ out.engage_pattern = input.engagePattern;
138
+ }
139
+ } else if (input.engageMode === 'mention') {
140
+ // Preserve mention-sticky if that's what's currently on the row;
141
+ // collapsing it to plain mention here would silently change router
142
+ // behavior (sticky engagement persists across replies). The wire
143
+ // doesn't expose sticky → it sees `mention` for both, but a PATCH
144
+ // that doesn't touch the sticky distinction shouldn't lose it.
145
+ out.engage_mode = current.engage_mode === 'mention-sticky' ? 'mention-sticky' : 'mention';
146
+ out.engage_pattern = null;
147
+ }
148
+ } else if (input.engagePattern !== undefined) {
149
+ // pattern body changed without changing the mode.
150
+ out.engage_pattern = input.engagePattern;
151
+ }
152
+
153
+ if (input.senderScope !== undefined) {
154
+ // wire 'unrestricted' → DB 'all'. validatePatchInput has already gated
155
+ // the union to the two known values, so the binary mapping is safe.
156
+ out.sender_scope = input.senderScope === 'allowlist' ? 'known' : 'all';
157
+ }
158
+ if (input.ignoredMessagePolicy !== undefined) {
159
+ out.ignored_message_policy = input.ignoredMessagePolicy === 'silent' ? 'accumulate' : 'drop';
160
+ }
161
+ if (input.priority !== undefined) {
162
+ out.priority = input.priority;
163
+ }
164
+ return out;
165
+ }
166
+
167
+ export type ValidatePatchResult = { ok: true; input: PatchInput } | { ok: false; reason: string };
168
+
169
+ export function validatePatchInput(body: unknown): ValidatePatchResult {
170
+ if (!body || typeof body !== 'object') return { ok: false, reason: 'body must be an object' };
171
+ const b = body as Record<string, unknown>;
172
+ const out: PatchInput = {};
173
+ if ('engageMode' in b) {
174
+ if (!VALID_API_ENGAGE_MODES.includes(b.engageMode as ApiEngageMode)) {
175
+ return { ok: false, reason: `invalid engageMode: ${String(b.engageMode)}` };
176
+ }
177
+ out.engageMode = b.engageMode as ApiEngageMode;
178
+ }
179
+ if ('engagePattern' in b) {
180
+ if (b.engagePattern !== null && typeof b.engagePattern !== 'string') {
181
+ return { ok: false, reason: 'engagePattern must be string or null' };
182
+ }
183
+ // Bare '.' is the wire-format sentinel for engageMode='all' — accepting
184
+ // it as a literal pattern would silently round-trip back as 'all' on the
185
+ // next read and lose the user's intent. Force the caller to disambiguate.
186
+ if (b.engagePattern === ALL_MESSAGES_PATTERN_SENTINEL) {
187
+ return {
188
+ ok: false,
189
+ reason:
190
+ "engagePattern '.' is reserved as the 'all' sentinel — use '\\\\.' (escaped) to match a literal dot, or set engageMode to 'all'",
191
+ };
192
+ }
193
+ out.engagePattern = b.engagePattern as string | null;
194
+ }
195
+ if ('senderScope' in b) {
196
+ if (!VALID_API_SENDER_SCOPES.includes(b.senderScope as ApiSenderScope)) {
197
+ return { ok: false, reason: `invalid senderScope: ${String(b.senderScope)}` };
198
+ }
199
+ out.senderScope = b.senderScope as ApiSenderScope;
200
+ }
201
+ if ('ignoredMessagePolicy' in b) {
202
+ if (!VALID_API_IGNORED_POLICIES.includes(b.ignoredMessagePolicy as ApiIgnoredMessagePolicy)) {
203
+ return { ok: false, reason: `invalid ignoredMessagePolicy: ${String(b.ignoredMessagePolicy)}` };
204
+ }
205
+ out.ignoredMessagePolicy = b.ignoredMessagePolicy as ApiIgnoredMessagePolicy;
206
+ }
207
+ if ('priority' in b) {
208
+ if (typeof b.priority !== 'number' || !Number.isFinite(b.priority)) {
209
+ return { ok: false, reason: 'priority must be a finite number' };
210
+ }
211
+ out.priority = b.priority;
212
+ }
213
+ return { ok: true, input: out };
214
+ }
package/src/config.ts CHANGED
@@ -12,9 +12,20 @@ export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_
12
12
  export const ASSISTANT_HAS_OWN_NUMBER =
13
13
  (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
14
14
 
15
- // Absolute paths needed for container mounts
16
- const PROJECT_ROOT = process.cwd();
17
- const HOME_DIR = process.env.HOME || os.homedir();
15
+ // Absolute paths needed for container mounts. Captured once at module load
16
+ // (process.cwd() at boot — the right value for the install dir) so every
17
+ // downstream consumer agrees on a single resolved root, and tests that
18
+ // chdir() can't desync against it. Exported for surfaces that need to
19
+ // self-register the install path (e.g. services.json `installDir`,
20
+ // paraclaw#115).
21
+ export const PROJECT_ROOT = process.cwd();
22
+ // Operator's home dir. Resolved once at module load — every downstream
23
+ // consumer that needs to expand `~` or derive a HOME-relative path imports
24
+ // this rather than calling `os.homedir()` itself, so a future precedence
25
+ // change (e.g. add a `PARACHUTE_AGENT_HOME` override) is one edit. Honors
26
+ // `HOME` env var first (sandbox-friendly, matches POSIX convention) before
27
+ // falling back to the real home dir.
28
+ export const HOME_DIR = process.env.HOME || os.homedir();
18
29
 
19
30
  // Parachute ecosystem root. Convention shared with parachute-hub, vault,
20
31
  // scribe — every module's persistent state lands under this directory
@@ -28,6 +39,15 @@ export const PARACHUTE_DIR = process.env.PARACHUTE_HOME || path.join(HOME_DIR, '
28
39
  // pre-existing files from the legacy dir on first 0.1.0 boot. The legacy
29
40
  // constants are exported for the migration to consult; nothing else should
30
41
  // read them. Drop in 0.2.0.
42
+ //
43
+ // Note (paraclaw#99): the allowlist sits at `<HOME>/.config/parachute-agent/`,
44
+ // NOT under `PARACHUTE_DIR`. This is intentional — the file is operator-host
45
+ // policy ("which paths can the agent ever mount on this host"), not runtime
46
+ // state of any one install. Two installs sharing a host should agree on the
47
+ // allowlist; a sandbox at `PARACHUTE_HOME=/tmp/sandbox` deliberately reads
48
+ // the same file the live install does. Runtime state (central DB +
49
+ // master.key) routes through `PARACHUTE_DIR` instead — see CENTRAL_DB_DIR
50
+ // below and the sandbox-isolation block in CLAUDE.md.
31
51
  export const ALLOWLIST_DIR = path.join(HOME_DIR, '.config', 'parachute-agent');
32
52
  export const LEGACY_ALLOWLIST_DIR = path.join(HOME_DIR, '.config', 'paraclaw');
33
53
  export const MOUNT_ALLOWLIST_PATH = path.join(ALLOWLIST_DIR, 'mount-allowlist.json');