@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
|
@@ -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', () => {
|
package/src/container-runtime.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
});
|
package/src/db/connection.ts
CHANGED
|
@@ -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
|
|
71
|
-
* key doesn't.
|
|
70
|
+
* rename. Idempotent.
|
|
72
71
|
*
|
|
73
|
-
*
|
|
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
|
-
|
|
84
|
-
|
|
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`.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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<
|
|
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
|
|
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', '
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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`);
|