@openparachute/agent 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/LICENSE +675 -21
- package/LICENSE-NANOCLAW-MIT +21 -0
- package/README.md +8 -1
- package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +1 -1
- package/package.json +2 -1
- package/scripts/init-cli-agent.ts +2 -1
- package/scripts/init-first-agent.ts +2 -1
- package/scripts/seed-discord.ts +2 -1
- package/src/channels/api-translator.test.ts +306 -0
- package/src/channels/api-translator.ts +214 -0
- package/src/config.ts +23 -3
- package/src/container-runtime.test.ts +101 -1
- package/src/container-runtime.ts +76 -1
- package/src/db/connection.migrate.test.ts +35 -2
- package/src/db/connection.ts +40 -5
- package/src/index.ts +6 -1
- package/src/mcp/tools/channels.test.ts +126 -0
- package/src/mcp/tools/channels.ts +33 -98
- package/src/modules/mount-security/expand-path.test.ts +82 -0
- package/src/modules/mount-security/index.ts +21 -10
- package/src/modules/permissions/sender-approval.test.ts +171 -0
- package/src/secrets/index.ts +127 -21
- package/src/secrets/secrets.test.ts +301 -4
- package/src/session-manager.attachments.test.ts +171 -0
- package/src/session-manager.dup-skip.test.ts +173 -0
- package/src/session-manager.ts +22 -4
- package/src/types.ts +4 -1
- package/src/web/routes/channels-mga-detail.test.ts +49 -2
- package/src/web/routes/channels.ts +25 -203
- package/src/web/routes/secrets.test.ts +46 -1
- package/src/web/routes/secrets.ts +35 -0
- package/src/web/server.ts +34 -13
- package/src/web/services-manifest.test.ts +37 -9
- package/src/web/services-manifest.ts +14 -9
- package/web/ui/index.html +2 -2
- package/web/ui/src/App.tsx +1 -1
- package/web/ui/src/lib/api.test.ts +2 -2
- package/web/ui/src/lib/api.ts +40 -2
- package/web/ui/src/lib/auth.test.ts +214 -1
- package/web/ui/src/lib/auth.ts +79 -22
- package/web/ui/src/routes/ChannelWireDetail.test.tsx +2 -2
- package/web/ui/src/routes/ChannelWireDetail.tsx +1 -1
- package/web/ui/src/routes/GroupDetail.test.tsx +206 -0
- package/web/ui/src/routes/GroupDetail.tsx +126 -1
- package/web/ui/src/routes/MessagingGroupDetail.test.tsx +1 -1
- package/web/ui/src/routes/SecretsList.tsx +22 -1
- package/web/ui/src/routes/VaultDetail.test.tsx +2 -0
|
@@ -0,0 +1,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
|
-
|
|
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'
|
|
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.
|
|
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
|
|
package/scripts/seed-discord.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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');
|