@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
@@ -22,9 +22,10 @@ import {
22
22
  readonlyMountArgs,
23
23
  stopContainer,
24
24
  ensureContainerRuntimeRunning,
25
+ ensureContainerImage,
25
26
  cleanupOrphans,
26
27
  } from './container-runtime.js';
27
- import { CONTAINER_INSTALL_LABEL, LEGACY_PARACLAW_INSTALL_LABEL } from './config.js';
28
+ import { CONTAINER_IMAGE, CONTAINER_INSTALL_LABEL, LEGACY_PARACLAW_INSTALL_LABEL } from './config.js';
28
29
  import { log } from './log.js';
29
30
 
30
31
  beforeEach(() => {
@@ -82,6 +83,105 @@ describe('ensureContainerRuntimeRunning', () => {
82
83
  });
83
84
  });
84
85
 
86
+ // --- ensureContainerImage ---
87
+
88
+ describe('ensureContainerImage', () => {
89
+ // Each test enumerates its own queue rather than going through a helper —
90
+ // throwing scenarios skip the retag call, so a generic helper would queue
91
+ // a `mockReturnValueOnce` that never gets consumed and leaks into the next
92
+ // test (vi.clearAllMocks doesn't drain the response queue).
93
+ const inspectFailed = () => {
94
+ mockExecSync.mockImplementationOnce(() => {
95
+ throw new Error('No such image');
96
+ });
97
+ };
98
+
99
+ it('no-ops when the expected image is already present', () => {
100
+ mockExecSync.mockReturnValueOnce('{}'); // inspect — image exists
101
+
102
+ ensureContainerImage();
103
+
104
+ expect(mockExecSync).toHaveBeenCalledTimes(1);
105
+ expect(mockExecSync).toHaveBeenCalledWith(
106
+ `${CONTAINER_RUNTIME_BIN} image inspect ${CONTAINER_IMAGE}`,
107
+ expect.objectContaining({ stdio: 'pipe' }),
108
+ );
109
+ expect(log.warn).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('retags from a current-prefix peer when the expected image is missing', () => {
113
+ // Operator dir-rename case: the previously-built image carries the old
114
+ // INSTALL_SLUG (cafef00d); the daemon now boots under a new slug.
115
+ const peer = 'parachute-agent-image-cafef00d:latest';
116
+ inspectFailed();
117
+ mockExecSync.mockReturnValueOnce(`${peer}\nnode:24-bookworm-slim\n`); // list
118
+ mockExecSync.mockReturnValueOnce(''); // tag
119
+
120
+ ensureContainerImage();
121
+
122
+ expect(mockExecSync).toHaveBeenCalledTimes(3);
123
+ expect(mockExecSync).toHaveBeenNthCalledWith(
124
+ 3,
125
+ `${CONTAINER_RUNTIME_BIN} tag ${peer} ${CONTAINER_IMAGE}`,
126
+ expect.objectContaining({ stdio: 'pipe' }),
127
+ );
128
+ expect(log.warn).toHaveBeenCalledWith(
129
+ 'Container image missing for current install slug — retagging from peer',
130
+ expect.objectContaining({ expected: CONTAINER_IMAGE, peer }),
131
+ );
132
+ });
133
+
134
+ it('retags from a pre-0.1.0 paraclaw-agent peer (one cycle of back-compat)', () => {
135
+ // Operator upgrades a pre-0.1.0 install: their on-disk image is named
136
+ // `paraclaw-agent-<slug>:latest`. Auto-retag rather than forcing a
137
+ // 5-minute rebuild they didn't ask for.
138
+ const legacyPeer = 'paraclaw-agent-cafef00d:latest';
139
+ inspectFailed();
140
+ mockExecSync.mockReturnValueOnce(legacyPeer);
141
+ mockExecSync.mockReturnValueOnce('');
142
+
143
+ ensureContainerImage();
144
+
145
+ expect(mockExecSync).toHaveBeenNthCalledWith(
146
+ 3,
147
+ `${CONTAINER_RUNTIME_BIN} tag ${legacyPeer} ${CONTAINER_IMAGE}`,
148
+ expect.objectContaining({ stdio: 'pipe' }),
149
+ );
150
+ });
151
+
152
+ it('throws an actionable error when no peer exists (fresh install, no build yet)', () => {
153
+ // No matching prefix on disk: only unrelated base images. Better to fail
154
+ // visibly at startup than crashloop code=125 on every container spawn.
155
+ inspectFailed();
156
+ mockExecSync.mockReturnValueOnce('node:24-bookworm-slim\nubuntu:22.04\n');
157
+
158
+ expect(() => ensureContainerImage()).toThrow(/build\.sh/);
159
+ expect(mockExecSync).toHaveBeenCalledTimes(2); // inspect + list, no tag
160
+ });
161
+
162
+ it('skips the (missing) expected name when picking a peer from the listing', () => {
163
+ // Belt-and-suspenders: the inspect already confirmed the expected name
164
+ // is absent, but the peer-search guard against picking it back up keeps
165
+ // the function safe if a future caller pre-checks a different way.
166
+ inspectFailed();
167
+ mockExecSync.mockReturnValueOnce(`${CONTAINER_IMAGE}\nparachute-agent-image-cafef00d:latest\n`);
168
+ mockExecSync.mockReturnValueOnce('');
169
+
170
+ ensureContainerImage();
171
+
172
+ const tagCall = mockExecSync.mock.calls.find((call) => String(call[0]).startsWith(`${CONTAINER_RUNTIME_BIN} tag`));
173
+ expect(tagCall).toBeDefined();
174
+ expect(String(tagCall![0])).toContain('parachute-agent-image-cafef00d:latest');
175
+ });
176
+
177
+ it('ignores arbitrary non-matching tags (e.g. base images, unrelated projects)', () => {
178
+ inspectFailed();
179
+ mockExecSync.mockReturnValueOnce('node:24-bookworm-slim\nparachute-vault:latest\nrandomthing:v2\n');
180
+
181
+ expect(() => ensureContainerImage()).toThrow(/build\.sh/);
182
+ });
183
+ });
184
+
85
185
  // --- cleanupOrphans ---
86
186
 
87
187
  describe('cleanupOrphans', () => {
@@ -5,9 +5,19 @@
5
5
  import { execSync } from 'child_process';
6
6
  import os from 'os';
7
7
 
8
- import { CONTAINER_INSTALL_LABEL, LEGACY_PARACLAW_INSTALL_LABEL } from './config.js';
8
+ import { CONTAINER_IMAGE, CONTAINER_INSTALL_LABEL, LEGACY_PARACLAW_INSTALL_LABEL } from './config.js';
9
9
  import { log } from './log.js';
10
10
 
11
+ // Per-install image tag schemas:
12
+ // - 0.1.0+: `parachute-agent-image-<8-hex-slug>:latest`
13
+ // - pre-0.1.0: `paraclaw-agent-<8-hex-slug>:latest` (kept for one cycle of
14
+ // back-compat so an operator who upgrades into a 0.1.x checkout
15
+ // without rebuilding the image still gets a working spawn;
16
+ // drop in 0.2.0 — same lifecycle as LEGACY_PARACLAW_INSTALL_LABEL).
17
+ // Both prefixes are stable + content-equivalent — Dockerfile baseline
18
+ // matches across slugs — so a `docker tag` of any peer is safe.
19
+ const PEER_IMAGE_PATTERN = /^(parachute-agent-image|paraclaw-agent)-[0-9a-f]{8}:latest$/;
20
+
11
21
  /** The container runtime binary name. */
12
22
  export const CONTAINER_RUNTIME_BIN = 'docker';
13
23
 
@@ -57,6 +67,71 @@ export function ensureContainerRuntimeRunning(): void {
57
67
  }
58
68
  }
59
69
 
70
+ /**
71
+ * Ensure the per-install container image is reachable before we start
72
+ * spawning sessions.
73
+ *
74
+ * INSTALL_SLUG = sha1(process.cwd())[:8], so an operator dir-rename
75
+ * (paraclaw#114) flips the slug. The previously-built image carries the
76
+ * OLD slug; the daemon goes to spawn against the NEW slug; `docker run`
77
+ * returns code=125 ("image not found") and every container spawn
78
+ * crashloops silently.
79
+ *
80
+ * Resolution path, ordered fail-fast → cheap → loud:
81
+ * 1. Expected tag present → no-op.
82
+ * 2. Any peer image (`parachute-agent-image-*` or pre-0.1.0
83
+ * `paraclaw-agent-*`) present → `docker tag` it to the expected
84
+ * name. Safe because the Dockerfile baseline doesn't fork per slug.
85
+ * 3. No peer found → throw with an actionable hint. The daemon was
86
+ * going to crashloop anyway; failing visibly at startup is strictly
87
+ * better than silent code=125 on every Telegram message.
88
+ */
89
+ export function ensureContainerImage(): void {
90
+ if (imageExists(CONTAINER_IMAGE)) {
91
+ log.debug('Container image present', { image: CONTAINER_IMAGE });
92
+ return;
93
+ }
94
+ const peer = findPeerImage(CONTAINER_IMAGE);
95
+ if (peer) {
96
+ // The dir-rename / upgrade case. Loud-warn so the operator can see in
97
+ // the log what happened — silent retags become folklore.
98
+ log.warn('Container image missing for current install slug — retagging from peer', {
99
+ expected: CONTAINER_IMAGE,
100
+ peer,
101
+ hint: 'Operator dir-rename or upgrade likely changed INSTALL_SLUG. Auto-retagging is safe; rebuild via ./container/build.sh next time you want to refresh dependencies.',
102
+ });
103
+ execSync(`${CONTAINER_RUNTIME_BIN} tag ${peer} ${CONTAINER_IMAGE}`, { stdio: 'pipe' });
104
+ return;
105
+ }
106
+ throw new Error(
107
+ `No parachute-agent container image found. Build one with: ./container/build.sh\n` +
108
+ `Expected image: ${CONTAINER_IMAGE}`,
109
+ );
110
+ }
111
+
112
+ function imageExists(ref: string): boolean {
113
+ try {
114
+ execSync(`${CONTAINER_RUNTIME_BIN} image inspect ${ref}`, { stdio: 'pipe' });
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function findPeerImage(exclude: string): string | null {
122
+ const output = execSync(`${CONTAINER_RUNTIME_BIN} images --format '{{.Repository}}:{{.Tag}}'`, {
123
+ stdio: ['pipe', 'pipe', 'pipe'],
124
+ encoding: 'utf-8',
125
+ });
126
+ // `docker images` lists newest-created first by default. Take the first
127
+ // matching peer so a recent rebuild wins over a stale legacy tag.
128
+ for (const ref of output.trim().split('\n').filter(Boolean)) {
129
+ if (ref === exclude) continue;
130
+ if (PEER_IMAGE_PATTERN.test(ref)) return ref;
131
+ }
132
+ return null;
133
+ }
134
+
60
135
  /**
61
136
  * Kill orphaned parachute-agent containers from THIS install's previous runs.
62
137
  *
@@ -13,6 +13,7 @@ import { join } from 'node:path';
13
13
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
14
14
 
15
15
  import { migrateCentralDbLocation, migrateMasterKeyLocation } from './connection.js';
16
+ import { log } from '../log.js';
16
17
 
17
18
  let tmp: string;
18
19
  let legacy: string;
@@ -129,15 +130,47 @@ describe('migrateMasterKeyLocation', () => {
129
130
  }
130
131
  });
131
132
 
132
- it('current key already exists legacy left untouched (no clobber)', () => {
133
+ it('current key already exists, no legacy noop (already migrated, or fresh install)', () => {
134
+ mkdirSync(currentDir, { recursive: true });
135
+ writeFileSync(currentKey, 'new-key-bytes-padding-to-32-aaaa');
136
+
137
+ migrateMasterKeyLocation(legacyDir, currentDir);
138
+
139
+ expect(readFileSync(currentKey, 'utf8')).toBe('new-key-bytes-padding-to-32-aaaa');
140
+ expect(existsSync(legacyKey)).toBe(false);
141
+ });
142
+
143
+ it('both keys exist — log a warn with recovery hint, leave both untouched', () => {
144
+ // Regression for parachute-agent#114: a previous boot generated a fresh
145
+ // key at the new path before the legacy was copied, so encrypted-secret
146
+ // rows sealed under the legacy key are now undecryptable. Don't
147
+ // clobber — surface it loudly so the operator can choose how to recover.
133
148
  mkdirSync(legacyDir, { recursive: true });
134
149
  writeFileSync(legacyKey, 'old-key-bytes-padding-to-32-aaaa');
135
150
  mkdirSync(currentDir, { recursive: true });
136
151
  writeFileSync(currentKey, 'new-key-bytes-padding-to-32-aaaa');
137
152
 
138
- migrateMasterKeyLocation(legacyDir, currentDir);
153
+ const original = log.warn;
154
+ const calls: Array<[string, Record<string, unknown> | undefined]> = [];
155
+ log.warn = ((msg: string, data?: Record<string, unknown>) => {
156
+ calls.push([msg, data]);
157
+ }) as typeof log.warn;
158
+ try {
159
+ migrateMasterKeyLocation(legacyDir, currentDir);
160
+ } finally {
161
+ log.warn = original;
162
+ }
139
163
 
140
164
  expect(readFileSync(currentKey, 'utf8')).toBe('new-key-bytes-padding-to-32-aaaa');
141
165
  expect(readFileSync(legacyKey, 'utf8')).toBe('old-key-bytes-padding-to-32-aaaa');
166
+ expect(calls).toHaveLength(1);
167
+ const [msg, ctx] = calls[0]!;
168
+ expect(msg).toMatch(/both/i);
169
+ const ctxObj = ctx as { legacy: string; current: string; note: string };
170
+ expect(ctxObj.legacy).toBe(legacyKey);
171
+ expect(ctxObj.current).toBe(currentKey);
172
+ // Recovery hint must name both paths so an operator can copy/paste it.
173
+ expect(ctxObj.note).toContain(legacyKey);
174
+ expect(ctxObj.note).toContain(currentKey);
142
175
  });
143
176
  });
@@ -67,10 +67,29 @@ export function migrateCentralDbLocation(
67
67
  * One-shot migration: copy `<PARACHUTE_DIR>/claw/master.key` to
68
68
  * `<PARACHUTE_DIR>/agent/master.key` so encrypted-secret rows decrypted under
69
69
  * the old key continue to decrypt after the paraclaw → parachute-agent
70
- * rename. Idempotent — noop if the new key already exists OR the legacy
71
- * key doesn't.
70
+ * rename. Idempotent.
72
71
  *
73
- * The legacy file is left in place — same rationale as the DB migration.
72
+ * Three cases:
73
+ * 1. Only legacy exists → copy to current, chmod 0600. Legacy stays as
74
+ * backup (operators verify and rm manually — same rationale as the DB).
75
+ * 2. Both exist → log a `warn` with both paths and a recovery
76
+ * hint, then noop. This is the silent-corruption bug from
77
+ * `parachute-agent#114`: a previous boot generated a fresh key at the
78
+ * new path before the legacy was copied, so the new key can't decrypt
79
+ * the operator's existing secret rows. Don't overwrite — a partial
80
+ * restore would lose secrets that were re-encrypted under the fresh
81
+ * key. Surface it loudly so the operator can choose.
82
+ * 3. Neither exists → noop (fresh install).
83
+ * 4. Only current exists → noop (already migrated, or fresh install where
84
+ * first boot created the key directly at the new path).
85
+ *
86
+ * MUST run before any caller of `loadOrCreateMasterKey()`. That function
87
+ * generates a fresh 32-byte key at the new path on first miss, which would
88
+ * shadow the legacy and trip case 2 on the next boot. `src/index.ts:main`
89
+ * calls this immediately after `migrateCentralDbLocation`. Standalone
90
+ * scripts (init-cli-agent, init-first-agent, seed-discord) call it for
91
+ * the same reason: any path that may end up touching the secrets store
92
+ * needs the legacy in place first.
74
93
  *
75
94
  * Path overrides exist for tests; production callers pass no args.
76
95
  */
@@ -80,8 +99,24 @@ export function migrateMasterKeyLocation(
80
99
  ): void {
81
100
  const legacyKey = path.join(legacyDir, 'master.key');
82
101
  const currentKey = path.join(currentDir, 'master.key');
83
- if (fs.existsSync(currentKey)) return;
84
- if (!fs.existsSync(legacyKey)) return;
102
+ const legacyExists = fs.existsSync(legacyKey);
103
+ const currentExists = fs.existsSync(currentKey);
104
+
105
+ if (currentExists && legacyExists) {
106
+ log.warn('Master key exists at both new and legacy locations', {
107
+ legacy: legacyKey,
108
+ current: currentKey,
109
+ note:
110
+ 'this means an earlier boot created a fresh key at the new path before the legacy was copied; ' +
111
+ `existing encrypted secrets were sealed under the legacy key and the fresh key cannot decrypt them. ` +
112
+ `If you have NOT yet entered new secrets under the fresh key, recover with: ` +
113
+ `mv ${currentKey} ${currentKey}.fresh-backup && cp ${legacyKey} ${currentKey} && chmod 600 ${currentKey}. ` +
114
+ `If you HAVE entered new secrets, the two are now mixed and you must re-enter the legacy ones manually.`,
115
+ });
116
+ return;
117
+ }
118
+ if (currentExists) return;
119
+ if (!legacyExists) return;
85
120
 
86
121
  fs.mkdirSync(currentDir, { recursive: true, mode: 0o700 });
87
122
  fs.copyFileSync(legacyKey, currentKey);
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import { CENTRAL_DB_PATH } from './config.js';
10
10
  import { migrateGroupsToClaudeLocal } from './claude-md-compose.js';
11
11
  import { initDb, migrateCentralDbLocation, migrateMasterKeyLocation } from './db/connection.js';
12
12
  import { runMigrations } from './db/migrations/index.js';
13
- import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js';
13
+ import { ensureContainerRuntimeRunning, ensureContainerImage, cleanupOrphans } from './container-runtime.js';
14
14
  import { startActiveDeliveryPoll, startSweepDeliveryPoll, setDeliveryAdapter, stopDeliveryPolls } from './delivery.js';
15
15
  import { startHostSweep, stopHostSweep } from './host-sweep.js';
16
16
  import { routeInbound } from './router.js';
@@ -89,6 +89,11 @@ async function main(): Promise<void> {
89
89
 
90
90
  // 2. Container runtime
91
91
  ensureContainerRuntimeRunning();
92
+ // Auto-retag the per-install image when an operator dir-rename has flipped
93
+ // INSTALL_SLUG out from under a previously-built image (paraclaw#114).
94
+ // Throws with an actionable hint when no image at all is on disk — better
95
+ // than the silent code=125 crashloop the daemon used to fall into.
96
+ ensureContainerImage();
92
97
  cleanupOrphans();
93
98
 
94
99
  // 3. Channel adapters
@@ -0,0 +1,126 @@
1
+ /**
2
+ * MCP-path coverage for `update-channel-wire`. The MCP SDK does not enforce
3
+ * a tool's `inputSchema` against `tools/call` arguments before dispatching
4
+ * to the handler (see comment on `ToolDef.inputSchema` in src/mcp/types.ts),
5
+ * so the handler must defensively gate enum-typed fields itself. Without
6
+ * the gate, a stale-schema client (cached pre-rc.6 senderScope vocabulary,
7
+ * or a hand-rolled call) would fall through the if/else patch-construction
8
+ * and silently no-op the column update — exactly the silent-coerce class
9
+ * paraclaw#94 set out to close.
10
+ */
11
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
12
+
13
+ import { closeDb, initTestDb, runMigrations } from '../../db/index.js';
14
+ import { createAgentGroup } from '../../db/agent-groups.js';
15
+ import {
16
+ createMessagingGroup,
17
+ createMessagingGroupAgent,
18
+ getMessagingGroupAgent,
19
+ } from '../../db/messaging-groups.js';
20
+ import type { ToolHandlerContext } from '../types.js';
21
+ import { channelTools } from './channels.js';
22
+
23
+ const updateTool = channelTools.find((t) => t.name === 'update-channel-wire')!;
24
+
25
+ const ctx: ToolHandlerContext = { effectiveScope: 'agent:admin', callerSubject: 'mcp:stdio' };
26
+
27
+ const now = (): string => new Date().toISOString();
28
+
29
+ function seedWire(over: { sender_scope?: 'all' | 'known' } = {}): void {
30
+ createAgentGroup({
31
+ id: 'ag_mcp',
32
+ name: 'MCP test',
33
+ folder: 'mcp-test',
34
+ agent_provider: null,
35
+ secret_mode: 'all',
36
+ created_at: now(),
37
+ });
38
+ createMessagingGroup({
39
+ id: 'mg_mcp',
40
+ channel_type: 'telegram',
41
+ platform_id: 'telegram:99:88',
42
+ name: null,
43
+ is_group: 0,
44
+ unknown_sender_policy: 'request_approval',
45
+ created_at: now(),
46
+ });
47
+ createMessagingGroupAgent({
48
+ id: 'mga_mcp',
49
+ messaging_group_id: 'mg_mcp',
50
+ agent_group_id: 'ag_mcp',
51
+ engage_mode: 'mention',
52
+ engage_pattern: null,
53
+ sender_scope: over.sender_scope ?? 'known',
54
+ ignored_message_policy: 'drop',
55
+ session_mode: 'shared',
56
+ priority: 0,
57
+ created_at: now(),
58
+ });
59
+ }
60
+
61
+ beforeEach(() => {
62
+ const db = initTestDb();
63
+ runMigrations(db);
64
+ });
65
+
66
+ afterEach(() => {
67
+ closeDb();
68
+ });
69
+
70
+ describe('mcp update-channel-wire — paraclaw#94 senderScope vocabulary', () => {
71
+ it("round-trips senderScope='unrestricted' → DB sender_scope='all' → response 'unrestricted'", async () => {
72
+ seedWire({ sender_scope: 'known' });
73
+
74
+ const result = (await updateTool.handler({ id: 'mga_mcp', senderScope: 'unrestricted' }, ctx)) as {
75
+ wire: { senderScope: string };
76
+ };
77
+
78
+ expect(result.wire.senderScope).toBe('unrestricted');
79
+ expect(getMessagingGroupAgent('mga_mcp')!.sender_scope).toBe('all');
80
+ });
81
+
82
+ it("rejects the legacy wire literal senderScope='all' instead of silent no-op", async () => {
83
+ // The bug shape: pre-fix, the handler's if/else pair only matched
84
+ // 'allowlist' and 'unrestricted'; legacy 'all' fell through and
85
+ // `patch.sender_scope` was never assigned, so updateMessagingGroupAgent
86
+ // ran with no sender_scope key and the column kept its previous value
87
+ // — server returned success, client believed the field changed,
88
+ // operator saw no error. Pin that the gate now refuses it.
89
+ seedWire({ sender_scope: 'known' });
90
+
91
+ await expect(updateTool.handler({ id: 'mga_mcp', senderScope: 'all' }, ctx)).rejects.toThrow(
92
+ /invalid senderScope: all/,
93
+ );
94
+
95
+ // And critically — the column was NOT silently mutated.
96
+ expect(getMessagingGroupAgent('mga_mcp')!.sender_scope).toBe('known');
97
+ });
98
+
99
+ it("rejects the legacy DB-side literal ignoredMessagePolicy='accumulate'", async () => {
100
+ // Same silent-coerce class on a sibling field — the DB stores
101
+ // 'accumulate' but the wire vocabulary is 'silent'. Pre-gate, sending
102
+ // 'accumulate' on the wire fell through both if-branches.
103
+ seedWire();
104
+
105
+ await expect(
106
+ updateTool.handler({ id: 'mga_mcp', ignoredMessagePolicy: 'accumulate' }, ctx),
107
+ ).rejects.toThrow(/invalid ignoredMessagePolicy: accumulate/);
108
+
109
+ expect(getMessagingGroupAgent('mga_mcp')!.ignored_message_policy).toBe('drop');
110
+ });
111
+
112
+ it('rejects an unknown engageMode instead of silent no-op via the engagePattern fallback', async () => {
113
+ // engageMode's if/else chain has a fourth branch that fires when the
114
+ // mode is unrecognized but engagePattern is present — so a typo'd
115
+ // engageMode used to silently update only the pattern. Pin the gate.
116
+ seedWire();
117
+
118
+ await expect(
119
+ updateTool.handler({ id: 'mga_mcp', engageMode: 'wave-hands', engagePattern: 'whatever' }, ctx),
120
+ ).rejects.toThrow(/invalid engageMode: wave-hands/);
121
+
122
+ const persisted = getMessagingGroupAgent('mga_mcp')!;
123
+ expect(persisted.engage_mode).toBe('mention');
124
+ expect(persisted.engage_pattern).toBeNull();
125
+ });
126
+ });
@@ -1,13 +1,25 @@
1
1
  /**
2
- * MCP tools for channel-wire CRUD. Mirrors `/api/channels`. The DB still
3
- * stores the pre-rebuild enum names (engage_mode = mention | pattern |
4
- * mention-sticky; sender_scope = all | known; ignored_message_policy = drop
5
- * | accumulate); the API contract these tools speak — same as the web API —
6
- * uses the new vocabulary (engageMode = mention | pattern | all; senderScope
7
- * = allowlist | all; ignoredMessagePolicy = drop | silent). The translator
8
- * is small so we inline it here rather than carving out a shared module
9
- * that would need its own seam through the route handler.
2
+ * MCP tools for channel-wire CRUD. Mirrors `/api/channels`.
3
+ *
4
+ * The wire-shape <-> DB-shape translator + patch validator now live in
5
+ * `src/channels/api-translator.ts` (paraclaw#123) and are shared with the
6
+ * HTTP route. See that module for the enum translation contract; this
7
+ * file owns the MCP tool plumbing only.
8
+ *
9
+ * The MCP SDK does NOT enforce `inputSchema` against `tools/call` args
10
+ * before dispatch (see ToolDef.inputSchema in src/mcp/types.ts), so the
11
+ * shared `validatePatchInput` doubles as the defensive gate this handler
12
+ * relied on inline before. paraclaw#94 / PR #122 closed the same
13
+ * silent-coerce class on the HTTP side; #123 brings the MCP side onto
14
+ * the same canonical validator.
10
15
  */
16
+ import {
17
+ apiToDbPatch,
18
+ type ChannelWireView,
19
+ rowToView,
20
+ validatePatchInput,
21
+ type WireJoinRow,
22
+ } from '../../channels/api-translator.js';
11
23
  import { getAgentGroup } from '../../db/agent-groups.js';
12
24
  import { getDb } from '../../db/connection.js';
13
25
  import {
@@ -16,79 +28,12 @@ import {
16
28
  getMessagingGroupAgent,
17
29
  updateMessagingGroupAgent,
18
30
  } from '../../db/messaging-groups.js';
19
- import type {
20
- EngageMode as DbEngageMode,
21
- IgnoredMessagePolicy as DbIgnoredMessagePolicy,
22
- SenderScope as DbSenderScope,
23
- MessagingGroupAgent,
24
- } from '../../types.js';
25
31
  import type { ToolDef } from '../types.js';
26
32
 
27
- type ApiEngageMode = 'mention' | 'pattern' | 'all';
28
- type ApiSenderScope = 'allowlist' | 'all';
29
- type ApiIgnoredMessagePolicy = 'drop' | 'silent';
30
-
31
- const ALL_PATTERN = '.';
32
-
33
- function dbToApiEngage(mode: DbEngageMode, pattern: string | null): ApiEngageMode {
34
- if (mode === 'pattern') return pattern === ALL_PATTERN ? 'all' : 'pattern';
35
- return 'mention';
36
- }
37
- function dbToApiSenderScope(s: DbSenderScope): ApiSenderScope {
38
- return s === 'known' ? 'allowlist' : 'all';
39
- }
40
- function dbToApiIgnoredPolicy(p: DbIgnoredMessagePolicy): ApiIgnoredMessagePolicy {
41
- return p === 'accumulate' ? 'silent' : 'drop';
42
- }
43
-
44
- interface WireRow extends MessagingGroupAgent {
45
- mg_channel_type: string;
46
- mg_platform_id: string;
47
- mg_name: string | null;
48
- ag_folder: string;
49
- ag_name: string;
50
- }
51
-
52
- interface ChannelWireView {
53
- id: string;
54
- channelType: string;
55
- messagingGroupId: string;
56
- platformId: string;
57
- displayName: string | null;
58
- agentGroupId: string;
59
- agentGroupFolder: string;
60
- agentGroupName: string;
61
- engageMode: ApiEngageMode;
62
- engagePattern: string | null;
63
- senderScope: ApiSenderScope;
64
- ignoredMessagePolicy: ApiIgnoredMessagePolicy;
65
- priority: number;
66
- createdAt: string;
67
- }
68
-
69
- function rowToView(row: WireRow): ChannelWireView {
70
- return {
71
- id: row.id,
72
- channelType: row.mg_channel_type,
73
- messagingGroupId: row.messaging_group_id,
74
- platformId: row.mg_platform_id,
75
- displayName: row.mg_name,
76
- agentGroupId: row.agent_group_id,
77
- agentGroupFolder: row.ag_folder,
78
- agentGroupName: row.ag_name,
79
- engageMode: dbToApiEngage(row.engage_mode, row.engage_pattern),
80
- engagePattern: row.engage_mode === 'pattern' && row.engage_pattern !== ALL_PATTERN ? row.engage_pattern : null,
81
- senderScope: dbToApiSenderScope(row.sender_scope),
82
- ignoredMessagePolicy: dbToApiIgnoredPolicy(row.ignored_message_policy),
83
- priority: row.priority,
84
- createdAt: row.created_at,
85
- };
86
- }
87
-
88
33
  function listAllWires(): ChannelWireView[] {
89
34
  return (
90
35
  getDb()
91
- .prepare<WireRow>(
36
+ .prepare<WireJoinRow>(
92
37
  `SELECT mga.*,
93
38
  mg.channel_type AS mg_channel_type,
94
39
  mg.platform_id AS mg_platform_id,
@@ -100,7 +45,7 @@ function listAllWires(): ChannelWireView[] {
100
45
  JOIN agent_groups ag ON ag.id = mga.agent_group_id
101
46
  ORDER BY mga.created_at DESC`,
102
47
  )
103
- .all() as WireRow[]
48
+ .all() as WireJoinRow[]
104
49
  ).map(rowToView);
105
50
  }
106
51
 
@@ -159,7 +104,7 @@ export const channelTools: ToolDef[] = [
159
104
  id: { type: 'string' },
160
105
  engageMode: { type: 'string', enum: ['mention', 'pattern', 'all'] },
161
106
  engagePattern: { type: ['string', 'null'] },
162
- senderScope: { type: 'string', enum: ['allowlist', 'all'] },
107
+ senderScope: { type: 'string', enum: ['allowlist', 'unrestricted'] },
163
108
  ignoredMessagePolicy: { type: 'string', enum: ['drop', 'silent'] },
164
109
  priority: { type: 'number' },
165
110
  },
@@ -170,26 +115,16 @@ export const channelTools: ToolDef[] = [
170
115
  const id = String(args.id ?? '');
171
116
  const current = getMessagingGroupAgent(id);
172
117
  if (!current) throw new Error(`channel wire not found: ${id}`);
173
- const patch: Partial<MessagingGroupAgent> = {};
174
- if (args.engageMode === 'all') {
175
- patch.engage_mode = 'pattern';
176
- patch.engage_pattern = ALL_PATTERN;
177
- } else if (args.engageMode === 'pattern') {
178
- patch.engage_mode = 'pattern';
179
- if (typeof args.engagePattern === 'string' && args.engagePattern !== ALL_PATTERN) {
180
- patch.engage_pattern = args.engagePattern;
181
- }
182
- } else if (args.engageMode === 'mention') {
183
- patch.engage_mode = current.engage_mode === 'mention-sticky' ? 'mention-sticky' : 'mention';
184
- patch.engage_pattern = null;
185
- } else if (typeof args.engagePattern === 'string' || args.engagePattern === null) {
186
- patch.engage_pattern = args.engagePattern as string | null;
187
- }
188
- if (args.senderScope === 'allowlist') patch.sender_scope = 'known';
189
- else if (args.senderScope === 'all') patch.sender_scope = 'all';
190
- if (args.ignoredMessagePolicy === 'silent') patch.ignored_message_policy = 'accumulate';
191
- else if (args.ignoredMessagePolicy === 'drop') patch.ignored_message_policy = 'drop';
192
- if (typeof args.priority === 'number' && Number.isFinite(args.priority)) patch.priority = args.priority;
118
+
119
+ // validatePatchInput inspects only the fields it knows; `id` and any
120
+ // future-compat keys are ignored. On `ok: false`, throw the reason —
121
+ // the HTTP route does the same translation on its end (400 + JSON
122
+ // error). Both surfaces now share the same rejection contract,
123
+ // including the engagePattern='.' sentinel guard.
124
+ const validated = validatePatchInput(args);
125
+ if (!validated.ok) throw new Error(validated.reason);
126
+
127
+ const patch = apiToDbPatch(validated.input, current);
193
128
  updateMessagingGroupAgent(id, patch);
194
129
  const after = getWireView(id);
195
130
  if (!after) throw new Error(`channel wire ${id} disappeared after update`);