@murmurv2/cli 2.11.0
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/LICENSE +21 -0
- package/README.md +53 -0
- package/bin/murmur-npm.mjs +6 -0
- package/package.json +42 -0
- package/runtime/bin/murmur-svc.exe +0 -0
- package/runtime/npm-runtime-manifest.json +299 -0
- package/runtime/package.json +1 -0
- package/runtime/packages/mcp-server/dist/src/index.js +1 -0
- package/runtime/packages/mcp-server/package.json +1 -0
- package/runtime/packages/setup/bin/murmur.mjs +26 -0
- package/runtime/packages/setup/dist/src/appdata.d.ts +9 -0
- package/runtime/packages/setup/dist/src/appdata.js +53 -0
- package/runtime/packages/setup/dist/src/cli.d.ts +9 -0
- package/runtime/packages/setup/dist/src/cli.js +130 -0
- package/runtime/packages/setup/dist/src/clients.d.ts +151 -0
- package/runtime/packages/setup/dist/src/clients.js +251 -0
- package/runtime/packages/setup/dist/src/commands.d.ts +35 -0
- package/runtime/packages/setup/dist/src/commands.js +115 -0
- package/runtime/packages/setup/dist/src/config.d.ts +49 -0
- package/runtime/packages/setup/dist/src/config.js +70 -0
- package/runtime/packages/setup/dist/src/doctor.d.ts +25 -0
- package/runtime/packages/setup/dist/src/doctor.js +174 -0
- package/runtime/packages/setup/dist/src/file-identity.d.ts +3 -0
- package/runtime/packages/setup/dist/src/file-identity.js +11 -0
- package/runtime/packages/setup/dist/src/index.d.ts +7 -0
- package/runtime/packages/setup/dist/src/index.js +7 -0
- package/runtime/packages/setup/dist/src/onboarding.d.ts +46 -0
- package/runtime/packages/setup/dist/src/onboarding.js +298 -0
- package/runtime/packages/setup/dist/src/outbox-attention.d.ts +51 -0
- package/runtime/packages/setup/dist/src/outbox-attention.js +98 -0
- package/runtime/packages/setup/dist/src/paths.d.ts +10 -0
- package/runtime/packages/setup/dist/src/paths.js +41 -0
- package/runtime/packages/setup/dist/src/platform/darwin.d.ts +17 -0
- package/runtime/packages/setup/dist/src/platform/darwin.js +270 -0
- package/runtime/packages/setup/dist/src/platform/linux.d.ts +9 -0
- package/runtime/packages/setup/dist/src/platform/linux.js +176 -0
- package/runtime/packages/setup/dist/src/platform/windows.d.ts +25 -0
- package/runtime/packages/setup/dist/src/platform/windows.js +189 -0
- package/runtime/packages/setup/dist/src/private-file.d.ts +5 -0
- package/runtime/packages/setup/dist/src/private-file.js +101 -0
- package/runtime/packages/setup/dist/src/reply-test.d.ts +25 -0
- package/runtime/packages/setup/dist/src/reply-test.js +75 -0
- package/runtime/packages/setup/dist/src/state.d.ts +3 -0
- package/runtime/packages/setup/dist/src/state.js +12 -0
- package/runtime/packages/setup/dist/src/status-line.d.ts +5 -0
- package/runtime/packages/setup/dist/src/status-line.js +19 -0
- package/runtime/packages/setup/dist/src/status.d.ts +137 -0
- package/runtime/packages/setup/dist/src/status.js +199 -0
- package/runtime/packages/setup/dist/src/types.d.ts +40 -0
- package/runtime/packages/setup/dist/src/types.js +1 -0
- package/runtime/packages/setup/dist/src/updates.d.ts +46 -0
- package/runtime/packages/setup/dist/src/updates.js +284 -0
- package/runtime/packages/setup/dist/src/verdict.d.ts +11 -0
- package/runtime/packages/setup/dist/src/verdict.js +159 -0
- package/runtime/packages/setup/package.json +1 -0
- package/runtime/plugins/claude-code/.claude-plugin/plugin.json +34 -0
- package/runtime/plugins/claude-code/.mcp.json +8 -0
- package/runtime/plugins/claude-code/scripts/configure-statusline.mjs +124 -0
- package/runtime/plugins/claude-code/scripts/statusline.mjs +210 -0
- package/runtime/plugins/claude-code/skills/inbox/SKILL.md +8 -0
- package/runtime/plugins/claude-code/skills/mark-read/SKILL.md +13 -0
- package/runtime/plugins/claude-code/skills/status/SKILL.md +13 -0
- package/runtime/scripts/ack-security.mjs +22 -0
- package/runtime/scripts/codex-app-server-wake.mjs +696 -0
- package/runtime/scripts/daemon-observation.mjs +48 -0
- package/runtime/scripts/doctor-protocol.mjs +96 -0
- package/runtime/scripts/lease.mjs +193 -0
- package/runtime/scripts/murmur-daemon.mjs +545 -0
- package/runtime/scripts/murmur-jetstream-advisory.mjs +11 -0
- package/runtime/scripts/murmur-shell-send.mjs +142 -0
- package/runtime/scripts/notify-router.mjs +309 -0
- package/runtime/scripts/runtime-capability.mjs +34 -0
- package/runtime/scripts/secure-state.mjs +105 -0
- package/runtime/scripts/wake-drain-claude.mjs +484 -0
- package/runtime/scripts/wake-monitor.mjs +653 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { mkdir, open, rmdir, lstat, unlink, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { isDeepStrictEqual, promisify } from 'node:util';
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import { SQLiteDedupeOutboxStore } from '@murmurv2/core';
|
|
8
|
+
import { createKeyPair, createSigningKeyPair } from '@murmurv2/security';
|
|
9
|
+
import { loadConfig, validateConfig, validAgentId } from './config.js';
|
|
10
|
+
import { writeState } from './state.js';
|
|
11
|
+
import { refuseVirtualizedAppData } from './appdata.js';
|
|
12
|
+
import { protectPrivateFile } from './private-file.js';
|
|
13
|
+
async function privateText(file, role) {
|
|
14
|
+
if (!path.isAbsolute(file))
|
|
15
|
+
throw new Error('onboarding.file-must-be-absolute');
|
|
16
|
+
// Name the option and the reason, never the path or contents: this is what a new user acts on.
|
|
17
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch((e) => {
|
|
18
|
+
if (e.code === 'ENOENT')
|
|
19
|
+
throw new Error(`onboarding.${role}-not-found`);
|
|
20
|
+
if (e.code === 'EPERM' || e.code === 'EACCES')
|
|
21
|
+
throw new Error(`onboarding.${role}-access-denied`);
|
|
22
|
+
throw e;
|
|
23
|
+
});
|
|
24
|
+
try {
|
|
25
|
+
const info = await handle.stat();
|
|
26
|
+
if (!info.isFile() || info.size > 16384)
|
|
27
|
+
throw new Error('onboarding.file-invalid');
|
|
28
|
+
return (await handle.readFile('utf8')).trim();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await handle.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Resolve existing ancestors without erasing symlink/.. filesystem semantics. */
|
|
35
|
+
async function canonicalPath(file) {
|
|
36
|
+
try {
|
|
37
|
+
return await realpath(file);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code !== 'ENOENT')
|
|
41
|
+
throw error;
|
|
42
|
+
const parent = path.dirname(file);
|
|
43
|
+
if (parent === file)
|
|
44
|
+
throw error;
|
|
45
|
+
return path.join(await canonicalPath(parent), path.basename(file));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function validateOutput(c, file) {
|
|
49
|
+
if (!path.isAbsolute(file))
|
|
50
|
+
throw new Error('onboarding.output-must-be-absolute');
|
|
51
|
+
let parent;
|
|
52
|
+
try {
|
|
53
|
+
parent = await realpath(path.dirname(file));
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code === 'ENOENT')
|
|
57
|
+
throw new Error('onboarding.output-parent-required');
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (!(await stat(parent)).isDirectory())
|
|
61
|
+
throw new Error('onboarding.output-parent-required');
|
|
62
|
+
// A nonexistent output parent must not become valid as a side effect of profile creation.
|
|
63
|
+
// On case-insensitive APFS, differently spelled missing paths can name that same directory.
|
|
64
|
+
const target = path.join(parent, path.basename(file)), profile = await canonicalPath(c.dataDir);
|
|
65
|
+
const relative = path.relative(profile, target);
|
|
66
|
+
if (!relative || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative))) {
|
|
67
|
+
throw new Error('onboarding.output-inside-profile');
|
|
68
|
+
}
|
|
69
|
+
const profileInfo = await stat(c.dataDir, { bigint: true }).catch((error) => {
|
|
70
|
+
if (error.code === 'ENOENT')
|
|
71
|
+
return null;
|
|
72
|
+
throw error;
|
|
73
|
+
});
|
|
74
|
+
// Existing path aliases are compared by filesystem identity, not by lowercasing names.
|
|
75
|
+
if (profileInfo)
|
|
76
|
+
for (let ancestor = parent;; ancestor = path.dirname(ancestor)) {
|
|
77
|
+
// NTFS file IDs can exceed Number.MAX_SAFE_INTEGER. Rounding neighboring
|
|
78
|
+
// directory IDs can falsely identify an outside output as part of the profile.
|
|
79
|
+
const info = await stat(ancestor, { bigint: true });
|
|
80
|
+
if (info.dev === profileInfo.dev && info.ino === profileInfo.ino)
|
|
81
|
+
throw new Error('onboarding.output-inside-profile');
|
|
82
|
+
if (path.dirname(ancestor) === ancestor)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const execFileAsync = promisify(execFile);
|
|
87
|
+
// These tools set the ACL of files holding keys and the broker token: never resolve them through PATH.
|
|
88
|
+
const system32 = (tool) => path.win32.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', tool);
|
|
89
|
+
let userSid;
|
|
90
|
+
/**
|
|
91
|
+
* POSIX modes do not restrict Windows files: a new file inherits its folder's DACL, which on a
|
|
92
|
+
* non-system drive grants Authenticated Users modify. Replace it with a protected DACL for the
|
|
93
|
+
* current user and SYSTEM (the LocalSystem service reads the profile). Only paths this process
|
|
94
|
+
* has just created are changed; an existing profile keeps the ACL its owner chose.
|
|
95
|
+
*/
|
|
96
|
+
async function restrictToCurrentUser(target, directory) {
|
|
97
|
+
if (process.platform !== 'win32')
|
|
98
|
+
return;
|
|
99
|
+
userSid ??= execFileAsync(system32('whoami.exe'), ['/user', '/fo', 'csv', '/nh'], { windowsHide: true }).then(({ stdout }) => {
|
|
100
|
+
const sid = /"(S-1-5-[0-9-]+)"\s*$/.exec(stdout.trim())?.[1];
|
|
101
|
+
if (!sid)
|
|
102
|
+
throw new Error('onboarding.user-sid-unavailable');
|
|
103
|
+
return sid;
|
|
104
|
+
}, () => { throw new Error('onboarding.user-sid-unavailable'); });
|
|
105
|
+
// A failed lookup is not remembered: the next attempt in this process measures again.
|
|
106
|
+
const sid = await userSid.catch((error) => { userSid = undefined; throw error; });
|
|
107
|
+
const inherit = directory ? '(OI)(CI)' : '';
|
|
108
|
+
await execFileAsync(system32('icacls.exe'), [target, '/inheritance:r', '/grant:r', `*${sid}:${inherit}(F)`, `*S-1-5-18:${inherit}(F)`], { windowsHide: true })
|
|
109
|
+
.catch(() => { throw new Error('onboarding.private-acl-failed'); });
|
|
110
|
+
}
|
|
111
|
+
async function outputBlob(file, value, prefix, beforeWrite) {
|
|
112
|
+
if (!path.isAbsolute(file))
|
|
113
|
+
throw new Error('onboarding.output-must-be-absolute');
|
|
114
|
+
const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
115
|
+
let written = false;
|
|
116
|
+
try {
|
|
117
|
+
// Protect the still-empty output before writing the invitation credential.
|
|
118
|
+
await protectPrivateFile(file, handle);
|
|
119
|
+
await beforeWrite?.();
|
|
120
|
+
await handle.writeFile(prefix + Buffer.from(JSON.stringify(value)).toString('base64') + '\n');
|
|
121
|
+
await handle.sync();
|
|
122
|
+
written = true;
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await handle.close();
|
|
126
|
+
if (!written)
|
|
127
|
+
await unlink(file).catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function locked(c, fn) {
|
|
131
|
+
// mkdir reports a path only when it created something: never rewrite an existing profile's ACL.
|
|
132
|
+
const created = await mkdir(c.dataDir, { recursive: true, mode: 0o700 });
|
|
133
|
+
if (created !== undefined) {
|
|
134
|
+
try {
|
|
135
|
+
await restrictToCurrentUser(c.dataDir, true);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
// Left in place, the unprotected directory would pass as an existing profile on retry and
|
|
139
|
+
// receive the keys under its inherited DACL. Remove exactly what mkdir just created (all empty).
|
|
140
|
+
for (let dir = c.dataDir;; dir = path.dirname(dir)) {
|
|
141
|
+
await rmdir(dir).catch(() => { });
|
|
142
|
+
if (dir === created || path.dirname(dir) === dir)
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const lock = path.join(c.dataDir, '.setup-write.lock');
|
|
149
|
+
await mkdir(lock, { mode: 0o700 });
|
|
150
|
+
try {
|
|
151
|
+
return await fn();
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await rmdir(lock);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function existing(c) {
|
|
158
|
+
try {
|
|
159
|
+
return await loadConfig(c);
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
if (e instanceof Error && e.message === 'config.missing')
|
|
163
|
+
return null;
|
|
164
|
+
throw e;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function newConfig(agentId, url, token) {
|
|
168
|
+
if (!validAgentId(agentId))
|
|
169
|
+
throw new Error('config.identity-invalid');
|
|
170
|
+
return validateConfig({ agentId, subject: `msg.${agentId}`, natsUrl: url, ...(token ? { natsToken: token } : {}),
|
|
171
|
+
keys: { encryption: await createKeyPair(), signing: await createSigningKeyPair() }, peers: {},
|
|
172
|
+
wake: { enabled: true }, ackSecurity: { emitSigned: true, requireSigned: true } });
|
|
173
|
+
}
|
|
174
|
+
async function saveChanged(c, previous, next) {
|
|
175
|
+
validateConfig(next);
|
|
176
|
+
if (previous && isDeepStrictEqual(previous, next))
|
|
177
|
+
return null;
|
|
178
|
+
const backup = previous ? path.join(c.dataDir, `agent-config.backup-${randomUUID()}.json`) : null;
|
|
179
|
+
if (backup)
|
|
180
|
+
await writeState(c, backup, previous);
|
|
181
|
+
await writeState(c, c.configPath, next);
|
|
182
|
+
if (!previous)
|
|
183
|
+
await writeState(c, path.join(c.dataDir, 'read-state.json'), { schema: 'murmur.read/1', agentId: next.agentId, rowid: 0 });
|
|
184
|
+
return backup;
|
|
185
|
+
}
|
|
186
|
+
const publicPeer = (c) => ({ agentId: c.agentId, subject: c.subject,
|
|
187
|
+
encryption: { publicKey: c.keys.encryption.publicKey }, signing: { publicKey: c.keys.signing.publicKey } });
|
|
188
|
+
async function readBlob(file, prefix, type) {
|
|
189
|
+
const text = await privateText(file, `${type}-file`);
|
|
190
|
+
if (!text.startsWith(prefix))
|
|
191
|
+
throw new Error('onboarding.invalid-blob');
|
|
192
|
+
const encoded = text.slice(prefix.length);
|
|
193
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || Buffer.from(encoded, 'base64').toString('base64') !== encoded)
|
|
194
|
+
throw new Error('onboarding.invalid-blob');
|
|
195
|
+
let value;
|
|
196
|
+
try {
|
|
197
|
+
value = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
throw new Error('onboarding.invalid-blob');
|
|
201
|
+
}
|
|
202
|
+
if (!value || value.v !== 1 || value.type !== type || !validAgentId(value.agentId) || value.subject !== `msg.${value.agentId}`)
|
|
203
|
+
throw new Error('onboarding.invalid-peer');
|
|
204
|
+
for (const key of [value.encryption?.publicKey, value.signing?.publicKey]) {
|
|
205
|
+
if (typeof key !== 'string' || Buffer.from(key, 'base64').length !== 32 || Buffer.from(key, 'base64').toString('base64') !== key)
|
|
206
|
+
throw new Error('onboarding.invalid-peer-key');
|
|
207
|
+
}
|
|
208
|
+
if (type === 'invite' && (typeof value.natsUrl !== 'string' || (value.natsToken !== undefined && typeof value.natsToken !== 'string')))
|
|
209
|
+
throw new Error('onboarding.invalid-broker');
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
function addPeer(config, peer) {
|
|
213
|
+
if (peer.agentId === config.agentId)
|
|
214
|
+
throw new Error('onboarding.self-peer');
|
|
215
|
+
const previous = config.peers[peer.agentId];
|
|
216
|
+
if (previous && (previous.subject !== peer.subject || previous.encryption.publicKey !== peer.encryption.publicKey || previous.signing.publicKey !== peer.signing.publicKey))
|
|
217
|
+
throw new Error('onboarding.peer-key-conflict');
|
|
218
|
+
const next = previous ?? { subject: peer.subject, encryption: { publicKey: peer.encryption.publicKey }, signing: { publicKey: peer.signing.publicKey } };
|
|
219
|
+
return { ...config, peers: { ...config.peers, [peer.agentId]: next } };
|
|
220
|
+
}
|
|
221
|
+
export async function initialize(c, options) {
|
|
222
|
+
await refuseVirtualizedAppData(c.dataDir);
|
|
223
|
+
return locked(c, async () => {
|
|
224
|
+
const previous = await existing(c);
|
|
225
|
+
if (previous) {
|
|
226
|
+
if (previous.agentId !== options.agentId || previous.natsUrl !== options.brokerUrl)
|
|
227
|
+
throw new Error('onboarding.existing-profile-conflict');
|
|
228
|
+
// Re-running init never rotates keys or replaces credentials.
|
|
229
|
+
return { schema: 'murmur.init/1', agentId: previous.agentId, dataDir: c.dataDir, serviceName: c.serviceName, existing: true };
|
|
230
|
+
}
|
|
231
|
+
const token = options.tokenFile ? await privateText(options.tokenFile, 'token-file') : undefined;
|
|
232
|
+
if (token?.includes('\n') || token?.includes('\r'))
|
|
233
|
+
throw new Error('onboarding.token-file-invalid');
|
|
234
|
+
const config = await newConfig(options.agentId, options.brokerUrl, token);
|
|
235
|
+
await saveChanged(c, null, config);
|
|
236
|
+
return { schema: 'murmur.init/1', agentId: config.agentId, dataDir: c.dataDir, serviceName: c.serviceName, existing: false };
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
export async function invite(c, outFile) {
|
|
240
|
+
await validateOutput(c, outFile);
|
|
241
|
+
const config = await loadConfig(c);
|
|
242
|
+
await outputBlob(outFile, { v: 1, type: 'invite', ...publicPeer(config), natsUrl: config.natsUrl, ...(config.natsToken ? { natsToken: config.natsToken } : {}) }, 'MURMUR:');
|
|
243
|
+
// The warning names what is actually inside. A person weighs "password" and "identity"
|
|
244
|
+
// differently, and the same sentence for both teaches them to ignore it.
|
|
245
|
+
const containsBrokerCredential = !!config.natsToken;
|
|
246
|
+
return { schema: 'murmur.invite/1', file: outFile, containsBrokerCredential,
|
|
247
|
+
instruction: containsBrokerCredential
|
|
248
|
+
? 'This file carries the broker address and its credential. Treat it like a password: send it only through a channel you would trust with one. Importing it does not prove pairing.'
|
|
249
|
+
: 'This file carries your identity and the broker address. Send it through a channel you trust. Importing it does not prove pairing.' };
|
|
250
|
+
}
|
|
251
|
+
export async function join(c, options) {
|
|
252
|
+
await validateOutput(c, options.replyOut);
|
|
253
|
+
const incoming = await readBlob(options.inviteFile, 'MURMUR:', 'invite');
|
|
254
|
+
// Before anything is created: a profile the service cannot see must not be written at all.
|
|
255
|
+
await refuseVirtualizedAppData(c.dataDir);
|
|
256
|
+
return locked(c, async () => {
|
|
257
|
+
const previous = await existing(c);
|
|
258
|
+
if (previous && (previous.agentId !== options.agentId || previous.natsUrl !== incoming.natsUrl))
|
|
259
|
+
throw new Error('onboarding.existing-profile-conflict');
|
|
260
|
+
const config = previous ?? await newConfig(options.agentId, incoming.natsUrl, incoming.natsToken);
|
|
261
|
+
const next = addPeer(config, incoming);
|
|
262
|
+
let backup = null;
|
|
263
|
+
// Reserve the reply path before changing config: an existing output must not half-import a profile.
|
|
264
|
+
await outputBlob(options.replyOut, { v: 1, type: 'reply', ...publicPeer(next) }, 'MURMUR-REPLY:', async () => {
|
|
265
|
+
backup = await saveChanged(c, previous, next);
|
|
266
|
+
});
|
|
267
|
+
return { schema: 'murmur.join/1', agentId: next.agentId, peerId: incoming.agentId, paired: null, replyFile: options.replyOut, backup, restartRequired: true, serviceName: c.serviceName };
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
async function clearPeerPoison(c, peerId) {
|
|
271
|
+
try {
|
|
272
|
+
const info = await lstat(c.storePath);
|
|
273
|
+
if (!info.isFile())
|
|
274
|
+
throw new Error('onboarding.store-invalid');
|
|
275
|
+
const store = new SQLiteDedupeOutboxStore(c.storePath);
|
|
276
|
+
try {
|
|
277
|
+
return { cleared: await store.clearPoisonedFrom(peerId), reason: null };
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
store.close();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
if (e.code === 'ENOENT')
|
|
285
|
+
return { cleared: 0, reason: null };
|
|
286
|
+
// Key import succeeded; an unreadable/locked store must not be reported as zero cleared.
|
|
287
|
+
return { cleared: null, reason: 'onboarding.poison-reset-failed' };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
export async function importPeer(c, replyFile) {
|
|
291
|
+
const incoming = await readBlob(replyFile, 'MURMUR-REPLY:', 'reply');
|
|
292
|
+
return locked(c, async () => {
|
|
293
|
+
const previous = await loadConfig(c), next = addPeer(previous, incoming);
|
|
294
|
+
const backup = await saveChanged(c, previous, next);
|
|
295
|
+
return { schema: 'murmur.peer/1', peerId: incoming.agentId, paired: null, backup,
|
|
296
|
+
poisonReset: await clearPeerPoison(c, incoming.agentId), restartRequired: true };
|
|
297
|
+
});
|
|
298
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { ServiceContext } from './types.js';
|
|
3
|
+
/** Metadata only. A dismissal never changes transport state or implies delivery. */
|
|
4
|
+
export declare function readOutboxAttention(c: ServiceContext, agentId: string, db: DatabaseSync): Promise<{
|
|
5
|
+
schema: "murmur.outbox-attention/1";
|
|
6
|
+
total: number;
|
|
7
|
+
pending: number;
|
|
8
|
+
dismissed: number;
|
|
9
|
+
items: {
|
|
10
|
+
msgId: string;
|
|
11
|
+
peer: string | null;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
failedAt: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
attempts: number;
|
|
16
|
+
token: string;
|
|
17
|
+
dismissed: boolean;
|
|
18
|
+
}[];
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
unknownReason: null;
|
|
21
|
+
}>;
|
|
22
|
+
export declare function listOutboxAttention(c: ServiceContext): Promise<{
|
|
23
|
+
schema: "murmur.outbox-attention/1";
|
|
24
|
+
total: number;
|
|
25
|
+
pending: number;
|
|
26
|
+
dismissed: number;
|
|
27
|
+
items: {
|
|
28
|
+
msgId: string;
|
|
29
|
+
peer: string | null;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
failedAt: string;
|
|
32
|
+
reason: string;
|
|
33
|
+
attempts: number;
|
|
34
|
+
token: string;
|
|
35
|
+
dismissed: boolean;
|
|
36
|
+
}[];
|
|
37
|
+
truncated: boolean;
|
|
38
|
+
unknownReason: null;
|
|
39
|
+
agentId: string;
|
|
40
|
+
}>;
|
|
41
|
+
/** Reversible local acknowledgement, serialized with setup writers. The outbox DB stays read-only. */
|
|
42
|
+
export declare function setOutboxDismissed(c: ServiceContext, msgId: string, token: string, expectedAgent: string, dismissed: boolean): Promise<{
|
|
43
|
+
schema: "murmur.outbox-action/1";
|
|
44
|
+
agentId: string;
|
|
45
|
+
msgId: string;
|
|
46
|
+
token: string;
|
|
47
|
+
dismissed: boolean;
|
|
48
|
+
transportState: "dlq";
|
|
49
|
+
historyPreserved: boolean;
|
|
50
|
+
resent: boolean;
|
|
51
|
+
}>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { mkdir, rmdir } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { loadConfig, readJson, safeError, validAgentId } from './config.js';
|
|
6
|
+
import { writeState } from './state.js';
|
|
7
|
+
const stateFile = (c) => path.join(c.dataDir, 'outbox-attention.json');
|
|
8
|
+
const validId = (v) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,150}$/.test(v);
|
|
9
|
+
const validToken = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
10
|
+
const columns = `msg_id AS msgId,subject,attempts,created_at AS createdAt,updated_at AS failedAt,last_error AS error,version,
|
|
11
|
+
json_extract(envelope_json,'$.recipients[0]') AS peer`;
|
|
12
|
+
const tokenFor = (row) => createHash('sha256').update(JSON.stringify([
|
|
13
|
+
row.msgId, row.subject, row.attempts, row.createdAt, row.failedAt, row.error, row.version, row.peer,
|
|
14
|
+
])).digest('hex');
|
|
15
|
+
async function saved(c, agentId) {
|
|
16
|
+
let value;
|
|
17
|
+
try {
|
|
18
|
+
value = await readJson(stateFile(c));
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code === 'ENOENT')
|
|
22
|
+
return { schema: 'murmur.outbox-dismissals/1', agentId, records: [] };
|
|
23
|
+
throw new Error('outbox.dismissals-unreadable');
|
|
24
|
+
}
|
|
25
|
+
if (value?.schema !== 'murmur.outbox-dismissals/1' || value.agentId !== agentId || !Array.isArray(value.records)
|
|
26
|
+
|| value.records.length > 1000 || value.records.some((r) => !validId(r?.msgId) || !validToken(r?.token))
|
|
27
|
+
|| new Set(value.records.map((r) => r.msgId)).size !== value.records.length)
|
|
28
|
+
throw new Error('outbox.dismissals-invalid');
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
/** Metadata only. A dismissal never changes transport state or implies delivery. */
|
|
32
|
+
export async function readOutboxAttention(c, agentId, db) {
|
|
33
|
+
const state = await saved(c, agentId), ignored = new Map(state.records.map(r => [r.msgId, r.token]));
|
|
34
|
+
// Keep the native status response below its 256 KiB transport limit.
|
|
35
|
+
const rows = db.prepare(`SELECT ${columns} FROM outbox WHERE status='dlq' ORDER BY updated_at DESC,msg_id LIMIT 201`).all();
|
|
36
|
+
if (rows.length > 200)
|
|
37
|
+
throw new Error('outbox.attention-limit-exceeded');
|
|
38
|
+
const items = rows.map(row => {
|
|
39
|
+
if (!validId(row.msgId))
|
|
40
|
+
throw new Error('outbox.record-invalid');
|
|
41
|
+
const token = tokenFor(row);
|
|
42
|
+
return { msgId: row.msgId, peer: validAgentId(row.peer) ? row.peer : null,
|
|
43
|
+
createdAt: row.createdAt, failedAt: row.failedAt, reason: safeError(row.error), attempts: row.attempts,
|
|
44
|
+
token, dismissed: ignored.get(row.msgId) === token };
|
|
45
|
+
});
|
|
46
|
+
// Outstanding items stay first, so older failures cannot disappear behind recent dismissals.
|
|
47
|
+
items.sort((a, b) => Number(a.dismissed) - Number(b.dismissed));
|
|
48
|
+
const dismissed = items.filter(i => i.dismissed).length;
|
|
49
|
+
return { schema: 'murmur.outbox-attention/1', total: items.length, pending: items.length - dismissed,
|
|
50
|
+
dismissed, items, truncated: false, unknownReason: null };
|
|
51
|
+
}
|
|
52
|
+
export async function listOutboxAttention(c) {
|
|
53
|
+
const config = await loadConfig(c), db = new DatabaseSync(c.storePath, { readOnly: true });
|
|
54
|
+
try {
|
|
55
|
+
db.exec('PRAGMA busy_timeout=1000; BEGIN');
|
|
56
|
+
return { agentId: config.agentId, ...await readOutboxAttention(c, config.agentId, db) };
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
db.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Reversible local acknowledgement, serialized with setup writers. The outbox DB stays read-only. */
|
|
63
|
+
export async function setOutboxDismissed(c, msgId, token, expectedAgent, dismissed) {
|
|
64
|
+
if (!validId(msgId) || !validToken(token) || !validAgentId(expectedAgent))
|
|
65
|
+
throw new Error('outbox.selection-invalid');
|
|
66
|
+
const lock = path.join(c.dataDir, '.setup-write.lock');
|
|
67
|
+
await mkdir(lock, { mode: 0o700 });
|
|
68
|
+
try {
|
|
69
|
+
const config = await loadConfig(c);
|
|
70
|
+
if (config.agentId !== expectedAgent)
|
|
71
|
+
throw new Error('outbox.identity-changed');
|
|
72
|
+
const db = new DatabaseSync(c.storePath, { readOnly: true });
|
|
73
|
+
try {
|
|
74
|
+
db.exec('PRAGMA busy_timeout=1000; BEGIN');
|
|
75
|
+
const row = db.prepare(`SELECT ${columns} FROM outbox WHERE status='dlq' AND msg_id=?`).get(msgId);
|
|
76
|
+
if (!row)
|
|
77
|
+
throw new Error('outbox.record-not-found');
|
|
78
|
+
if (tokenFor(row) !== token)
|
|
79
|
+
throw new Error('outbox.record-changed');
|
|
80
|
+
const state = await saved(c, config.agentId);
|
|
81
|
+
const records = state.records.filter(r => r.msgId !== msgId);
|
|
82
|
+
if (dismissed)
|
|
83
|
+
records.push({ msgId, token });
|
|
84
|
+
if (records.length > 1000)
|
|
85
|
+
throw new Error('outbox.dismissals-limit-exceeded');
|
|
86
|
+
if (JSON.stringify(records) !== JSON.stringify(state.records))
|
|
87
|
+
await writeState(c, stateFile(c), { ...state, records });
|
|
88
|
+
return { schema: 'murmur.outbox-action/1', agentId: config.agentId, msgId, token, dismissed,
|
|
89
|
+
transportState: 'dlq', historyPreserved: true, resent: false };
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
db.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await rmdir(lock);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ServiceContext } from "./types.js";
|
|
2
|
+
export declare function resolveContext(options?: {
|
|
3
|
+
dataDir?: string;
|
|
4
|
+
repoRoot?: string;
|
|
5
|
+
nodePath?: string;
|
|
6
|
+
serviceName?: string;
|
|
7
|
+
platform?: NodeJS.Platform;
|
|
8
|
+
home?: string;
|
|
9
|
+
env?: NodeJS.ProcessEnv;
|
|
10
|
+
}): ServiceContext;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
export function resolveContext(options = {}) {
|
|
6
|
+
const env = options.env ?? process.env;
|
|
7
|
+
const platform = options.platform ?? process.platform;
|
|
8
|
+
const home = options.home ?? homedir();
|
|
9
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
10
|
+
const defaultDir = platform === "darwin" ? paths.join(home, "Library", "Application Support", "Murmur")
|
|
11
|
+
: platform === "win32" ? paths.join(env.LOCALAPPDATA || paths.join(home, "AppData", "Local"), "Murmur")
|
|
12
|
+
: paths.join(env.XDG_STATE_HOME || paths.join(home, ".local", "state"), "murmur");
|
|
13
|
+
let selected = options.dataDir;
|
|
14
|
+
if (selected === undefined) {
|
|
15
|
+
if (env.DATA_DIR !== undefined) {
|
|
16
|
+
if (!env.DATA_DIR)
|
|
17
|
+
throw new Error("config.data-dir-empty");
|
|
18
|
+
if (env.MURMUR_DATA_DIR && paths.normalize(env.DATA_DIR) !== paths.normalize(env.MURMUR_DATA_DIR))
|
|
19
|
+
throw new Error("config.data-dir-conflict");
|
|
20
|
+
selected = env.DATA_DIR;
|
|
21
|
+
}
|
|
22
|
+
else
|
|
23
|
+
selected = env.MURMUR_DATA_DIR ?? defaultDir;
|
|
24
|
+
}
|
|
25
|
+
const dataDir = paths.normalize(selected);
|
|
26
|
+
if (!paths.isAbsolute(dataDir))
|
|
27
|
+
throw new Error("config.data-dir-must-be-absolute");
|
|
28
|
+
// The default service name hashes the path as written (see AGENTS.md), and shell completion appends
|
|
29
|
+
// a separator to folders: refuse it rather than silently select a second service for one profile.
|
|
30
|
+
if (dataDir.length > paths.parse(dataDir).root.length && /[\\/]$/.test(dataDir))
|
|
31
|
+
throw new Error("profile.trailing-separator");
|
|
32
|
+
const repoRoot = paths.normalize(options.repoRoot ?? fileURLToPath(new URL("../../../../", import.meta.url))).replace(/[\\/]$/, "");
|
|
33
|
+
const nodePath = paths.normalize(options.nodePath ?? process.execPath);
|
|
34
|
+
if (!paths.isAbsolute(repoRoot) || !paths.isAbsolute(nodePath))
|
|
35
|
+
throw new Error("config.runtime-path-must-be-absolute");
|
|
36
|
+
const serviceName = options.serviceName ?? `murmur-${createHash("sha256").update(dataDir).digest("hex").slice(0, 12)}`;
|
|
37
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,100}$/.test(serviceName))
|
|
38
|
+
throw new Error("config.invalid-service-name");
|
|
39
|
+
return { dataDir, repoRoot, nodePath, serviceName, configPath: paths.join(dataDir, "agent-config.json"),
|
|
40
|
+
storePath: paths.join(dataDir, "murmur.db"), logDir: paths.join(dataDir, "logs") };
|
|
41
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PlatformAdapter } from "../types.js";
|
|
2
|
+
export interface CommandResult {
|
|
3
|
+
code: number;
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
}
|
|
7
|
+
export type CommandRunner = (file: string, args: string[]) => Promise<CommandResult>;
|
|
8
|
+
export interface DarwinOptions {
|
|
9
|
+
homeDir?: string;
|
|
10
|
+
uid?: number;
|
|
11
|
+
env?: NodeJS.ProcessEnv;
|
|
12
|
+
run?: CommandRunner;
|
|
13
|
+
applicationDirs?: string[];
|
|
14
|
+
/** Fully replaces executable search roots, primarily for hermetic tests. */
|
|
15
|
+
executableDirs?: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare function createDarwinAdapter(options?: DarwinOptions): PlatformAdapter;
|