@openparachute/agent 0.1.1 → 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 +42 -0
- package/docs/design/2026-05-02-channel-policy-and-approval-routing.md +1 -1
- package/package.json +1 -1
- package/scripts/init-cli-agent.ts +2 -1
- package/scripts/init-first-agent.ts +2 -1
- package/scripts/seed-discord.ts +2 -1
- package/src/channels/api-translator.test.ts +306 -0
- package/src/channels/api-translator.ts +214 -0
- package/src/config.ts +23 -3
- package/src/container-runtime.test.ts +101 -1
- package/src/container-runtime.ts +76 -1
- package/src/db/connection.migrate.test.ts +35 -2
- package/src/db/connection.ts +40 -5
- package/src/index.ts +6 -1
- package/src/mcp/tools/channels.test.ts +126 -0
- package/src/mcp/tools/channels.ts +33 -98
- package/src/modules/mount-security/expand-path.test.ts +82 -0
- package/src/modules/mount-security/index.ts +21 -10
- package/src/modules/permissions/sender-approval.test.ts +171 -0
- package/src/secrets/index.ts +127 -21
- package/src/secrets/secrets.test.ts +301 -4
- package/src/session-manager.attachments.test.ts +171 -0
- package/src/session-manager.dup-skip.test.ts +173 -0
- package/src/session-manager.ts +22 -4
- package/src/types.ts +4 -1
- package/src/web/routes/channels-mga-detail.test.ts +49 -2
- package/src/web/routes/channels.ts +25 -203
- package/src/web/routes/secrets.test.ts +46 -1
- package/src/web/routes/secrets.ts +35 -0
- package/src/web/server.ts +34 -13
- package/src/web/services-manifest.test.ts +37 -9
- package/src/web/services-manifest.ts +14 -9
- package/web/ui/index.html +2 -2
- package/web/ui/src/App.tsx +1 -1
- package/web/ui/src/lib/api.test.ts +2 -2
- package/web/ui/src/lib/api.ts +40 -2
- package/web/ui/src/lib/auth.test.ts +214 -1
- package/web/ui/src/lib/auth.ts +79 -22
- package/web/ui/src/routes/ChannelWireDetail.test.tsx +2 -2
- package/web/ui/src/routes/ChannelWireDetail.tsx +1 -1
- package/web/ui/src/routes/GroupDetail.test.tsx +206 -0
- package/web/ui/src/routes/GroupDetail.tsx +126 -1
- package/web/ui/src/routes/MessagingGroupDetail.test.tsx +1 -1
- package/web/ui/src/routes/SecretsList.tsx +22 -1
- package/web/ui/src/routes/VaultDetail.test.tsx +2 -0
package/src/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');
|
|
@@ -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
|
+
});
|