@murmurv2/cli 2.11.0 → 2.12.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/README.md +37 -12
- package/package.json +2 -1
- package/runtime/bin/murmur-svc.exe +0 -0
- package/runtime/npm-runtime-manifest.json +97 -61
- package/runtime/package.json +1 -1
- package/runtime/packages/setup/bin/murmur.mjs +5 -3
- package/runtime/packages/setup/dist/src/cli.d.ts +1 -1
- package/runtime/packages/setup/dist/src/cli.js +49 -9
- package/runtime/packages/setup/dist/src/clients.js +8 -2
- package/runtime/packages/setup/dist/src/commands.d.ts +3 -1
- package/runtime/packages/setup/dist/src/commands.js +17 -6
- package/runtime/packages/setup/dist/src/config.d.ts +2 -0
- package/runtime/packages/setup/dist/src/config.js +61 -0
- package/runtime/packages/setup/dist/src/doctor.d.ts +12 -0
- package/runtime/packages/setup/dist/src/doctor.js +19 -2
- package/runtime/packages/setup/dist/src/invitation-server.d.ts +2 -0
- package/runtime/packages/setup/dist/src/invitation-server.js +45 -0
- package/runtime/packages/setup/dist/src/message-status.d.ts +4 -0
- package/runtime/packages/setup/dist/src/message-status.js +10 -0
- package/runtime/packages/setup/dist/src/onboarding.d.ts +19 -6
- package/runtime/packages/setup/dist/src/onboarding.js +46 -22
- package/runtime/packages/setup/dist/src/outbox-attention.js +3 -21
- package/runtime/packages/setup/dist/src/platform/darwin.js +7 -1
- package/runtime/packages/setup/dist/src/platform/linux.js +21 -16
- package/runtime/packages/setup/dist/src/platform/windows.js +67 -3
- package/runtime/packages/setup/dist/src/status.d.ts +17 -2
- package/runtime/packages/setup/dist/src/status.js +78 -18
- package/runtime/packages/setup/dist/src/types.d.ts +11 -1
- package/runtime/packages/setup/dist/src/verdict.js +4 -2
- package/runtime/packages/setup/dist/src/wake-attention.d.ts +11 -0
- package/runtime/packages/setup/dist/src/wake-attention.js +44 -0
- package/runtime/packages/setup/package.json +1 -1
- package/runtime/scripts/codex-app-server-wake.mjs +169 -32
- package/runtime/scripts/daemon-contacts.mjs +56 -0
- package/runtime/scripts/daemon-flush-tick.mjs +25 -0
- package/runtime/scripts/daemon-link-watch.mjs +60 -0
- package/runtime/scripts/daemon-observation.mjs +74 -18
- package/runtime/scripts/murmur-daemon.mjs +45 -31
- package/runtime/scripts/wake-drain-claude.mjs +69 -32
- package/runtime/scripts/wake-monitor.mjs +46 -18
|
@@ -2,12 +2,17 @@ import path from 'node:path';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
4
|
import { access, mkdir, realpath, rmdir, stat } from 'node:fs/promises';
|
|
5
|
-
import { loadConfig, readJson } from './config.js';
|
|
5
|
+
import { loadConfig, readJson, safeError } from './config.js';
|
|
6
6
|
import { readStatus } from './status.js';
|
|
7
|
+
import { assistantRead, wakeColumns } from './message-status.js';
|
|
7
8
|
import { writeState } from './state.js';
|
|
8
9
|
/** Observe the configured directory only; this does not prove a live daemon's log destination. */
|
|
9
|
-
export async function readLogPath(context) {
|
|
10
|
+
export async function readLogPath(context, adapter) {
|
|
10
11
|
const config = await loadConfig(context);
|
|
12
|
+
if (adapter?.logDirectory) {
|
|
13
|
+
return { schema: 'murmur.logs/1', agentId: config.agentId, dataDir: context.dataDir,
|
|
14
|
+
serviceName: context.serviceName, logDir: await adapter.logDirectory(context), source: 'native' };
|
|
15
|
+
}
|
|
11
16
|
try {
|
|
12
17
|
const dataDir = await realpath(context.dataDir);
|
|
13
18
|
const logDir = await realpath(context.logDir);
|
|
@@ -53,8 +58,12 @@ export async function setWakeEnabled(context, adapter, enabled, apply = false) {
|
|
|
53
58
|
let applyError = null;
|
|
54
59
|
if (apply) {
|
|
55
60
|
try {
|
|
56
|
-
await
|
|
57
|
-
|
|
61
|
+
if ((await readStatus({ context, adapter })).service.state === 'running-unmanaged')
|
|
62
|
+
applyError = 'service.running-unmanaged';
|
|
63
|
+
else {
|
|
64
|
+
await adapter.stop(context);
|
|
65
|
+
await adapter.start(context);
|
|
66
|
+
}
|
|
58
67
|
}
|
|
59
68
|
catch {
|
|
60
69
|
applyError = 'wake.service-apply-failed';
|
|
@@ -102,12 +111,14 @@ export async function readInbox(context, limit = 20) {
|
|
|
102
111
|
const unread = cursor === null ? null : Number(db.prepare("SELECT COUNT(*) AS n FROM local_messages WHERE direction='inbound' AND rowid>?").get(cursor).n);
|
|
103
112
|
const rows = db.prepare(`SELECT rowid,conversation_id AS conversationId,msg_id AS msgId,sender,text,
|
|
104
113
|
created_at AS createdAt,transport,channel_id AS channelId,sender_member_id AS senderMemberId,
|
|
105
|
-
addressee_member_id AS addresseeMemberId FROM local_messages WHERE direction='inbound'
|
|
114
|
+
addressee_member_id AS addresseeMemberId,${wakeColumns(db)} FROM local_messages WHERE direction='inbound'
|
|
106
115
|
ORDER BY created_at DESC,rowid DESC LIMIT ?`).all(limit);
|
|
107
116
|
db.exec('COMMIT');
|
|
108
117
|
return { schema: 'murmur.inbox/1', agentId: config.agentId, readCursor: cursor,
|
|
109
118
|
unread,
|
|
110
|
-
messages: rows.map(({ rowid, ...row }) => ({ ...row,
|
|
119
|
+
messages: rows.map(({ rowid, ...row }) => ({ ...row, assistantRead: assistantRead(row.wake_status),
|
|
120
|
+
wake_error: row.wake_error ? safeError(row.wake_error) : null,
|
|
121
|
+
unread: cursor === null ? null : Number(rowid) > cursor })) };
|
|
111
122
|
}
|
|
112
123
|
finally {
|
|
113
124
|
db.close();
|
|
@@ -47,3 +47,5 @@ export declare function validateConfig(input: unknown): AgentConfig;
|
|
|
47
47
|
export declare function readJson(file: string): Promise<any>;
|
|
48
48
|
export declare function loadConfig(context: ServiceContext): Promise<AgentConfig>;
|
|
49
49
|
export declare function safeError(error: unknown): string;
|
|
50
|
+
/** CLI presentation only; JSON consumers retain the stable, secret-free codes. */
|
|
51
|
+
export declare function humanError(error: unknown): string;
|
|
@@ -68,3 +68,64 @@ export function safeError(error) {
|
|
|
68
68
|
}
|
|
69
69
|
return "operation-failed";
|
|
70
70
|
}
|
|
71
|
+
/** CLI presentation only; JSON consumers retain the stable, secret-free codes. */
|
|
72
|
+
export function humanError(error) {
|
|
73
|
+
const code = safeError(error);
|
|
74
|
+
if (code === 'onboarding.invite-public-server-required') {
|
|
75
|
+
return 'This Invitation needs a public Server address. Ask for its public address and pass it with --broker.';
|
|
76
|
+
}
|
|
77
|
+
if (code === 'onboarding.invite-server-address-invalid') {
|
|
78
|
+
return 'Enter a valid public Server address with --broker, such as nats://server.example.com:4222. Do not include an access key in the address.';
|
|
79
|
+
}
|
|
80
|
+
const messages = {
|
|
81
|
+
'accepted-turn-unobservable': 'The Assistant accepted this message, but its outcome cannot be checked. Inspect its session before dismissing the alert; do not send the instruction again.',
|
|
82
|
+
'wake.selection-invalid': 'Choose a message and the expected Identity, then try again.',
|
|
83
|
+
'wake.identity-changed': 'The selected Identity has changed. Check its name before dismissing this alert.',
|
|
84
|
+
'wake.dismiss-not-eligible': 'This alert cannot be dismissed here. Check the message status and the selected Identity.',
|
|
85
|
+
'wake.store-invalid': 'Choose the original message history owned by your account, then try again.',
|
|
86
|
+
'onboarding.input-required': 'Provide the Invitation or Reply using standard input or choose its saved file.',
|
|
87
|
+
'onboarding.input-conflict': 'Choose one source for the Invitation or Reply: standard input or a saved file.',
|
|
88
|
+
'onboarding.stdin-required': 'Send the Invitation or Reply through standard input, or choose its saved file.',
|
|
89
|
+
'onboarding.input-too-large': 'This Invitation or Reply is too long. Ask your colleague to send a new one.',
|
|
90
|
+
'onboarding.file-invalid': 'This file cannot be used. Choose the saved Invitation or Reply your colleague sent.',
|
|
91
|
+
'onboarding.invalid-blob': 'This Invitation or Reply is incomplete or damaged. Copy the whole line your colleague sent.',
|
|
92
|
+
'onboarding.invalid-peer': 'This is not the expected Invitation or Reply. Ask your colleague to send the correct one.',
|
|
93
|
+
'onboarding.invalid-peer-key': 'The Contact details are damaged. Ask your colleague for a new Invitation or Reply.',
|
|
94
|
+
'onboarding.invalid-broker': 'The Invitation has invalid Server details. Ask your colleague for a new Invitation.',
|
|
95
|
+
'onboarding.self-peer': 'This is your own Identity. Ask your colleague to send their Reply.',
|
|
96
|
+
'onboarding.peer-key-conflict': 'This Contact has different Identity details. Verify the change with your colleague before replacing the Contact.',
|
|
97
|
+
'onboarding.existing-profile-conflict': 'Another Identity already uses this folder. Choose that Identity or an empty folder.',
|
|
98
|
+
'onboarding.file-must-be-absolute': 'Choose the input file using its full path.',
|
|
99
|
+
'onboarding.output-must-be-absolute': 'Choose the destination file using its full path.',
|
|
100
|
+
'onboarding.output-parent-required': 'Choose an existing folder to save the Invitation or Reply.',
|
|
101
|
+
'onboarding.output-inside-profile': 'Choose a destination outside the folder containing your Identity settings.',
|
|
102
|
+
'onboarding.token-file-invalid': 'The Server access key must be one line. Ask for the correct key and save it again.',
|
|
103
|
+
'onboarding.private-acl-failed': 'The file could not be protected. Choose a private folder you can write to and try again.',
|
|
104
|
+
'onboarding.user-sid-unavailable': 'Your account could not be verified. Sign in again and retry.',
|
|
105
|
+
'onboarding.poison-reset-failed': 'The Contact was saved, but waiting messages could not be prepared. Check the connection and retry.',
|
|
106
|
+
'config.missing': 'Set up your Identity or choose its existing folder, then try again.',
|
|
107
|
+
'config.identity-invalid': 'Choose an Identity name using letters, numbers, hyphens or underscores.',
|
|
108
|
+
'config.broker-url-invalid': 'Enter a valid Server address supplied by your colleague and try again.',
|
|
109
|
+
'service.running-unmanaged': 'This Service is running outside this app. Manage it where it was started.',
|
|
110
|
+
'service.previous-installation': 'A Service from a previous Murmur version uses this name. Replace it with: service install --replace-previous.',
|
|
111
|
+
'service.foreign-image': 'Another program already uses this Service name; Murmur will not change or remove it.',
|
|
112
|
+
'cli.unknown-command': 'This command is not available. Run with --help to choose an action.',
|
|
113
|
+
'cli.unknown-option': 'This option is not available. Run with --help to check the command.',
|
|
114
|
+
'cli.invalid-arguments': 'The command is incomplete. Run with --help to check the required options.',
|
|
115
|
+
};
|
|
116
|
+
if (Object.hasOwn(messages, code))
|
|
117
|
+
return messages[code];
|
|
118
|
+
if (/^onboarding\.(invite|reply|token)-file-not-found$/.test(code))
|
|
119
|
+
return 'The selected file was not found. Choose the saved Invitation, Reply or Server access key again.';
|
|
120
|
+
if (/^onboarding\.(invite|reply|token)-file-access-denied$/.test(code))
|
|
121
|
+
return 'The selected file cannot be opened. Choose a file your account can read.';
|
|
122
|
+
if (code.startsWith('cli.required-option:'))
|
|
123
|
+
return 'A required option is missing. Run with --help to complete the command.';
|
|
124
|
+
if (code.startsWith('system.EEXIST'))
|
|
125
|
+
return 'The destination already exists or another setup is in progress. Choose a new destination or wait for setup to finish.';
|
|
126
|
+
if (/^system\.(EACCES|EPERM)/.test(code))
|
|
127
|
+
return 'Access was denied. Choose a folder your account can read and write.';
|
|
128
|
+
if (code === 'database is locked')
|
|
129
|
+
return 'Your messages are busy with another action. Wait a moment and try again.';
|
|
130
|
+
return 'This action could not be completed. Check your settings and use --json to share diagnostics with your support contact.';
|
|
131
|
+
}
|
|
@@ -17,6 +17,18 @@ export declare function runDoctor({ context, adapter, peer, timeoutMs }: DoctorO
|
|
|
17
17
|
schema: string;
|
|
18
18
|
generatedAt: string;
|
|
19
19
|
agentId: string | null;
|
|
20
|
+
peerCheck: {
|
|
21
|
+
peerId: string;
|
|
22
|
+
state: "connected" | "failed" | "not-checked";
|
|
23
|
+
lastExchangeAt: string | null;
|
|
24
|
+
requestMsgId: string | null;
|
|
25
|
+
replyMsgId: string | null;
|
|
26
|
+
reason: string | null;
|
|
27
|
+
} | null;
|
|
28
|
+
wakeFault: {
|
|
29
|
+
reason: string;
|
|
30
|
+
fixHint: string;
|
|
31
|
+
} | null;
|
|
20
32
|
stages: Record<string, unknown>[];
|
|
21
33
|
summary: {
|
|
22
34
|
worst: string;
|
|
@@ -88,6 +88,7 @@ export async function probeRoundtrip(c, config, peerId, connection, timeoutMs) {
|
|
|
88
88
|
const failureHints = (serviceName) => ({
|
|
89
89
|
'config.missing': 'No profile here yet: run murmur join --data-dir <this profile> --agent-id <your-agent-id> --invite-file <invite file> --reply-out <new reply file>',
|
|
90
90
|
'daemon.not-running': `Install or start the service: murmur service install --data-dir <this profile> --service-name ${serviceName} (or service start with the same options if installed; on Windows from an administrator terminal)`,
|
|
91
|
+
'accepted-turn-unobservable': 'Inspect the Assistant session. After reviewing the unknown outcome, use murmur wake dismiss --data-dir <this profile> --msg-id <message-id> --expected-agent <your-identity> to acknowledge it without running it again.',
|
|
91
92
|
});
|
|
92
93
|
export async function runDoctor({ context, adapter, peer, timeoutMs = 10000 }) {
|
|
93
94
|
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 60000)
|
|
@@ -95,6 +96,8 @@ export async function runDoctor({ context, adapter, peer, timeoutMs = 10000 }) {
|
|
|
95
96
|
const generatedAt = new Date().toISOString(), stages = [];
|
|
96
97
|
let config = null, connection;
|
|
97
98
|
let snapshot;
|
|
99
|
+
let peerCheck = peer
|
|
100
|
+
? { peerId: peer, state: 'not-checked', lastExchangeAt: null, requestMsgId: null, replyMsgId: null, reason: null } : null;
|
|
98
101
|
let failedStage = null, worst = 'ok';
|
|
99
102
|
const stage = async (id, title, fn) => {
|
|
100
103
|
const start = Date.now();
|
|
@@ -120,7 +123,7 @@ export async function runDoctor({ context, adapter, peer, timeoutMs = 10000 }) {
|
|
|
120
123
|
await stage('config', 'Configuration', async () => { config = await loadConfig(context); return { detail: 'Identity, keys and peer configuration validated' }; });
|
|
121
124
|
await stage('daemon', 'Daemon and store', async () => {
|
|
122
125
|
snapshot = await readStatus({ context, adapter });
|
|
123
|
-
if (snapshot.service.state
|
|
126
|
+
if (!['running', 'running-unmanaged'].includes(snapshot.service.state))
|
|
124
127
|
throw new Error('daemon.not-running');
|
|
125
128
|
if (!snapshot.service.observedStorePath || await realpath(snapshot.service.observedStorePath) !== await realpath(context.storePath))
|
|
126
129
|
throw new Error('daemon.store-unverified');
|
|
@@ -149,10 +152,14 @@ export async function runDoctor({ context, adapter, peer, timeoutMs = 10000 }) {
|
|
|
149
152
|
if (!peer)
|
|
150
153
|
return { state: 'warn', reason: 'roundtrip.peer-required', detail: 'No diagnostic peer selected; no message sent', fixHint: 'Run murmur doctor --peer <configured-agent-id> --json' };
|
|
151
154
|
const proof = await probeRoundtrip(context, config, peer, connection, timeoutMs);
|
|
155
|
+
peerCheck = { peerId: peer, state: 'connected', lastExchangeAt: proof.verifiedAt,
|
|
156
|
+
requestMsgId: proof.msgId, replyMsgId: proof.replyMsgId, reason: null };
|
|
152
157
|
return { detail: `Authenticated reply persisted from ${proof.peerId}` };
|
|
153
158
|
});
|
|
154
159
|
await stage('wake', 'Wake mode and responder', async () => {
|
|
155
160
|
const s = await readStatus({ context, adapter });
|
|
161
|
+
if (s.wake.faults.lastFault === 'accepted-turn-unobservable')
|
|
162
|
+
throw new Error('accepted-turn-unobservable');
|
|
156
163
|
if (s.wake.effective.enabled === null)
|
|
157
164
|
return { state: 'warn', reason: 'wake.unmeasured', detail: 'No fresh runtime evidence' };
|
|
158
165
|
if (s.wake.effective.needsRestart)
|
|
@@ -170,5 +177,15 @@ export async function runDoctor({ context, adapter, peer, timeoutMs = 10000 }) {
|
|
|
170
177
|
finally {
|
|
171
178
|
await connection?.close();
|
|
172
179
|
}
|
|
173
|
-
|
|
180
|
+
if (peerCheck && peerCheck.state !== 'connected') {
|
|
181
|
+
peerCheck.state = 'failed';
|
|
182
|
+
peerCheck.reason = String(stages.find(s => s.state === 'fail')?.reason ?? 'roundtrip.not-checked');
|
|
183
|
+
}
|
|
184
|
+
// A local accepted-outcome fault is useful even when an earlier network stage
|
|
185
|
+
// blocked the wake probe. This read sends no message and changes no state.
|
|
186
|
+
const local = await readStatus({ context, adapter });
|
|
187
|
+
const wakeFault = local.wake.faults.lastFault === 'accepted-turn-unobservable'
|
|
188
|
+
? { reason: 'accepted-turn-unobservable', fixHint: failureHints(context.serviceName)['accepted-turn-unobservable'] } : null;
|
|
189
|
+
return { schema: 'murmur.doctor/1', generatedAt, agentId: config?.agentId ?? null,
|
|
190
|
+
peerCheck, wakeFault, stages, summary: { worst, failedStage } };
|
|
174
191
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { BlockList, isIP } from 'node:net';
|
|
2
|
+
const nonPublic = new BlockList();
|
|
3
|
+
for (const [network, prefix] of [
|
|
4
|
+
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
|
5
|
+
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
|
6
|
+
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
|
7
|
+
['224.0.0.0', 4], ['240.0.0.0', 4],
|
|
8
|
+
])
|
|
9
|
+
nonPublic.addSubnet(network, prefix, 'ipv4');
|
|
10
|
+
for (const [network, prefix] of [
|
|
11
|
+
['::', 96], ['64:ff9b::', 96], ['2001:db8::', 32], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
|
|
12
|
+
])
|
|
13
|
+
nonPublic.addSubnet(network, prefix, 'ipv6');
|
|
14
|
+
const privateSuffixes = ['local', 'localhost', 'localdomain', 'ts.net', 'internal', 'home.arpa', 'lan'];
|
|
15
|
+
/** Address policy only: Invitation creation never connects or claims reachability. */
|
|
16
|
+
export function invitationServerAddress(value) {
|
|
17
|
+
let host;
|
|
18
|
+
try {
|
|
19
|
+
// URL parsing otherwise silently removes whitespace and controls. Never let the
|
|
20
|
+
// checked address and the value carried in the Invitation disagree that way.
|
|
21
|
+
if (/[\s\x00-\x1f\x7f\\]/u.test(value))
|
|
22
|
+
throw new Error();
|
|
23
|
+
const url = new URL(value);
|
|
24
|
+
if (!['nats:', 'tls:'].includes(url.protocol) || !url.host || url.username || url.password
|
|
25
|
+
|| url.search || url.hash || !['', '/'].includes(url.pathname) || url.port === '0')
|
|
26
|
+
throw new Error();
|
|
27
|
+
// nats/tls are opaque-host URL schemes. The standard HTTP host parser also
|
|
28
|
+
// recognizes numeric, short, hex and octal IPv4 spellings (127.1, 0x7f000001),
|
|
29
|
+
// percent-encoded hostnames, case and IPv4-mapped IPv6, without a DNS lookup.
|
|
30
|
+
host = new URL(`http://${url.host}`).hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
|
|
31
|
+
if (!isIP(host) && (host.length > 253 || !host.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))))
|
|
32
|
+
throw new Error();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error('onboarding.invite-server-address-invalid');
|
|
36
|
+
}
|
|
37
|
+
const family = isIP(host);
|
|
38
|
+
// BlockList checks IPv4 rules for mapped IPv6 too; ::ffff:192.168.1.1 cannot
|
|
39
|
+
// turn a private IPv4 destination into a public Invitation address.
|
|
40
|
+
const privateAddress = family ? nonPublic.check(host, family === 4 ? 'ipv4' : 'ipv6')
|
|
41
|
+
: !host.includes('.') || privateSuffixes.some(suffix => host === suffix || host.endsWith(`.${suffix}`));
|
|
42
|
+
if (privateAddress)
|
|
43
|
+
throw new Error('onboarding.invite-public-server-required');
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
/** Older stores remain readable without a migration or an invented wake result. */
|
|
3
|
+
export declare function wakeColumns(db: DatabaseSync): string;
|
|
4
|
+
export declare function assistantRead(wakeStatus: unknown): boolean | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Older stores remain readable without a migration or an invented wake result. */
|
|
2
|
+
export function wakeColumns(db) {
|
|
3
|
+
const columns = new Set(db.prepare('PRAGMA table_info(local_messages)').all().map(row => row.name));
|
|
4
|
+
return ['wake_status', 'wake_updated_at', 'wake_error'].map(name => columns.has(name) ? name : `NULL AS ${name}`).join(',');
|
|
5
|
+
}
|
|
6
|
+
export function assistantRead(wakeStatus) {
|
|
7
|
+
if (wakeStatus === 'handled')
|
|
8
|
+
return true;
|
|
9
|
+
return ['pending', 'inflight', 'failed', 'dlq', 'stored-only', 'muted'].includes(String(wakeStatus)) ? false : null;
|
|
10
|
+
}
|
|
@@ -10,27 +10,36 @@ export declare function initialize(c: ServiceContext, options: {
|
|
|
10
10
|
serviceName: string;
|
|
11
11
|
existing: boolean;
|
|
12
12
|
}>;
|
|
13
|
-
export declare function invite(c: ServiceContext, outFile
|
|
13
|
+
export declare function invite(c: ServiceContext, outFile?: string, options?: {
|
|
14
|
+
brokerUrl?: string;
|
|
15
|
+
}): Promise<{
|
|
14
16
|
schema: string;
|
|
15
|
-
file: string;
|
|
17
|
+
file: string | null;
|
|
18
|
+
invitation: string;
|
|
16
19
|
containsBrokerCredential: boolean;
|
|
17
20
|
instruction: string;
|
|
18
21
|
}>;
|
|
19
22
|
export declare function join(c: ServiceContext, options: {
|
|
20
23
|
agentId: string;
|
|
21
|
-
inviteFile
|
|
22
|
-
|
|
24
|
+
inviteFile?: string;
|
|
25
|
+
invitation?: string;
|
|
26
|
+
replyOut?: string;
|
|
23
27
|
}): Promise<{
|
|
24
28
|
schema: string;
|
|
25
29
|
agentId: string;
|
|
26
30
|
peerId: any;
|
|
27
31
|
paired: null;
|
|
28
|
-
replyFile: string;
|
|
32
|
+
replyFile: string | null;
|
|
33
|
+
reply: string;
|
|
29
34
|
backup: null;
|
|
30
35
|
restartRequired: boolean;
|
|
36
|
+
contactsReload: {
|
|
37
|
+
mechanism: string;
|
|
38
|
+
state: string;
|
|
39
|
+
};
|
|
31
40
|
serviceName: string;
|
|
32
41
|
}>;
|
|
33
|
-
export declare function importPeer(c: ServiceContext, replyFile
|
|
42
|
+
export declare function importPeer(c: ServiceContext, replyFile?: string, reply?: string): Promise<{
|
|
34
43
|
schema: string;
|
|
35
44
|
peerId: any;
|
|
36
45
|
paired: null;
|
|
@@ -43,4 +52,8 @@ export declare function importPeer(c: ServiceContext, replyFile: string): Promis
|
|
|
43
52
|
reason: string;
|
|
44
53
|
};
|
|
45
54
|
restartRequired: boolean;
|
|
55
|
+
contactsReload: {
|
|
56
|
+
mechanism: string;
|
|
57
|
+
state: string;
|
|
58
|
+
};
|
|
46
59
|
}>;
|
|
@@ -10,6 +10,7 @@ import { loadConfig, validateConfig, validAgentId } from './config.js';
|
|
|
10
10
|
import { writeState } from './state.js';
|
|
11
11
|
import { refuseVirtualizedAppData } from './appdata.js';
|
|
12
12
|
import { protectPrivateFile } from './private-file.js';
|
|
13
|
+
import { invitationServerAddress } from './invitation-server.js';
|
|
13
14
|
async function privateText(file, role) {
|
|
14
15
|
if (!path.isAbsolute(file))
|
|
15
16
|
throw new Error('onboarding.file-must-be-absolute');
|
|
@@ -108,7 +109,13 @@ async function restrictToCurrentUser(target, directory) {
|
|
|
108
109
|
await execFileAsync(system32('icacls.exe'), [target, '/inheritance:r', '/grant:r', `*${sid}:${inherit}(F)`, `*S-1-5-18:${inherit}(F)`], { windowsHide: true })
|
|
109
110
|
.catch(() => { throw new Error('onboarding.private-acl-failed'); });
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
+
const encodeBlob = (value) => {
|
|
113
|
+
const text = 'MURMUR:' + Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
114
|
+
if (Buffer.byteLength(text + '\n') > 16384)
|
|
115
|
+
throw new Error('onboarding.input-too-large');
|
|
116
|
+
return text;
|
|
117
|
+
};
|
|
118
|
+
async function outputBlob(file, text, beforeWrite) {
|
|
112
119
|
if (!path.isAbsolute(file))
|
|
113
120
|
throw new Error('onboarding.output-must-be-absolute');
|
|
114
121
|
const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
|
|
@@ -117,7 +124,7 @@ async function outputBlob(file, value, prefix, beforeWrite) {
|
|
|
117
124
|
// Protect the still-empty output before writing the invitation credential.
|
|
118
125
|
await protectPrivateFile(file, handle);
|
|
119
126
|
await beforeWrite?.();
|
|
120
|
-
await handle.writeFile(
|
|
127
|
+
await handle.writeFile(text + '\n');
|
|
121
128
|
await handle.sync();
|
|
122
129
|
written = true;
|
|
123
130
|
}
|
|
@@ -185,12 +192,20 @@ async function saveChanged(c, previous, next) {
|
|
|
185
192
|
}
|
|
186
193
|
const publicPeer = (c) => ({ agentId: c.agentId, subject: c.subject,
|
|
187
194
|
encryption: { publicKey: c.keys.encryption.publicKey }, signing: { publicKey: c.keys.signing.publicKey } });
|
|
188
|
-
async function readBlob(
|
|
189
|
-
|
|
195
|
+
async function readBlob(input, type) {
|
|
196
|
+
if ((input.file === undefined) === (input.text === undefined))
|
|
197
|
+
throw new Error('onboarding.input-required');
|
|
198
|
+
const text = (input.file !== undefined ? await privateText(input.file, `${type}-file`) : input.text).trim();
|
|
199
|
+
if (Buffer.byteLength(text, 'utf8') > 16384)
|
|
200
|
+
throw new Error('onboarding.input-too-large');
|
|
201
|
+
// Accept existing private files as well as the shared, type-tagged string format.
|
|
202
|
+
const prefix = type === 'reply' && text.startsWith('MURMUR-REPLY:') ? 'MURMUR-REPLY:' : 'MURMUR:';
|
|
190
203
|
if (!text.startsWith(prefix))
|
|
191
204
|
throw new Error('onboarding.invalid-blob');
|
|
192
205
|
const encoded = text.slice(prefix.length);
|
|
193
|
-
|
|
206
|
+
const canonical = /^[A-Za-z0-9_-]+$/.test(encoded) && Buffer.from(encoded, 'base64url').toString('base64url') === encoded;
|
|
207
|
+
const legacy = /^[A-Za-z0-9+/]+={0,2}$/.test(encoded) && Buffer.from(encoded, 'base64').toString('base64') === encoded;
|
|
208
|
+
if (!canonical && !legacy)
|
|
194
209
|
throw new Error('onboarding.invalid-blob');
|
|
195
210
|
let value;
|
|
196
211
|
try {
|
|
@@ -236,21 +251,24 @@ export async function initialize(c, options) {
|
|
|
236
251
|
return { schema: 'murmur.init/1', agentId: config.agentId, dataDir: c.dataDir, serviceName: c.serviceName, existing: false };
|
|
237
252
|
});
|
|
238
253
|
}
|
|
239
|
-
export async function invite(c, outFile) {
|
|
240
|
-
|
|
254
|
+
export async function invite(c, outFile, options = {}) {
|
|
255
|
+
if (outFile !== undefined)
|
|
256
|
+
await validateOutput(c, outFile);
|
|
241
257
|
const config = await loadConfig(c);
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
258
|
+
const server = invitationServerAddress(options.brokerUrl ?? config.natsUrl);
|
|
259
|
+
const invitation = encodeBlob({ v: 1, type: 'invite', ...publicPeer(config), natsUrl: server, ...(config.natsToken ? { natsToken: config.natsToken } : {}) });
|
|
260
|
+
if (outFile !== undefined)
|
|
261
|
+
await outputBlob(outFile, invitation);
|
|
245
262
|
const containsBrokerCredential = !!config.natsToken;
|
|
246
|
-
return { schema: 'murmur.invite/1', file: outFile, containsBrokerCredential,
|
|
263
|
+
return { schema: 'murmur.invite/1', file: outFile ?? null, invitation, containsBrokerCredential,
|
|
247
264
|
instruction: containsBrokerCredential
|
|
248
|
-
? 'This
|
|
249
|
-
: 'This
|
|
265
|
+
? 'This Invitation contains a Server access key. Send it personally to your colleague. After exchanging the Reply, check the connection.'
|
|
266
|
+
: 'This Invitation contains your Identity and the Server address. Send it to your colleague. After exchanging the Reply, check the connection.' };
|
|
250
267
|
}
|
|
251
268
|
export async function join(c, options) {
|
|
252
|
-
|
|
253
|
-
|
|
269
|
+
if (options.replyOut !== undefined)
|
|
270
|
+
await validateOutput(c, options.replyOut);
|
|
271
|
+
const incoming = await readBlob({ file: options.inviteFile, text: options.invitation }, 'invite');
|
|
254
272
|
// Before anything is created: a profile the service cannot see must not be written at all.
|
|
255
273
|
await refuseVirtualizedAppData(c.dataDir);
|
|
256
274
|
return locked(c, async () => {
|
|
@@ -261,10 +279,15 @@ export async function join(c, options) {
|
|
|
261
279
|
const next = addPeer(config, incoming);
|
|
262
280
|
let backup = null;
|
|
263
281
|
// Reserve the reply path before changing config: an existing output must not half-import a profile.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
282
|
+
const reply = encodeBlob({ v: 1, type: 'reply', ...publicPeer(next) });
|
|
283
|
+
const save = async () => { backup = await saveChanged(c, previous, next); };
|
|
284
|
+
if (options.replyOut !== undefined)
|
|
285
|
+
await outputBlob(options.replyOut, reply, save);
|
|
286
|
+
else
|
|
287
|
+
await save();
|
|
288
|
+
return { schema: 'murmur.join/1', agentId: next.agentId, peerId: incoming.agentId, paired: null,
|
|
289
|
+
replyFile: options.replyOut ?? null, reply, backup, restartRequired: false,
|
|
290
|
+
contactsReload: { mechanism: 'config-file', state: 'pending' }, serviceName: c.serviceName };
|
|
268
291
|
});
|
|
269
292
|
}
|
|
270
293
|
async function clearPeerPoison(c, peerId) {
|
|
@@ -287,12 +310,13 @@ async function clearPeerPoison(c, peerId) {
|
|
|
287
310
|
return { cleared: null, reason: 'onboarding.poison-reset-failed' };
|
|
288
311
|
}
|
|
289
312
|
}
|
|
290
|
-
export async function importPeer(c, replyFile) {
|
|
291
|
-
const incoming = await readBlob(replyFile,
|
|
313
|
+
export async function importPeer(c, replyFile, reply) {
|
|
314
|
+
const incoming = await readBlob({ file: replyFile, text: reply }, 'reply');
|
|
292
315
|
return locked(c, async () => {
|
|
293
316
|
const previous = await loadConfig(c), next = addPeer(previous, incoming);
|
|
294
317
|
const backup = await saveChanged(c, previous, next);
|
|
295
318
|
return { schema: 'murmur.peer/1', peerId: incoming.agentId, paired: null, backup,
|
|
296
|
-
poisonReset: await clearPeerPoison(c, incoming.agentId), restartRequired:
|
|
319
|
+
poisonReset: await clearPeerPoison(c, incoming.agentId), restartRequired: false,
|
|
320
|
+
contactsReload: { mechanism: 'config-file', state: 'pending' } };
|
|
297
321
|
});
|
|
298
322
|
}
|
|
@@ -1,33 +1,15 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import {
|
|
2
|
+
import { outboxAttentionToken as tokenFor, readOutboxDismissals } from '@murmurv2/core';
|
|
3
3
|
import { mkdir, rmdir } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { loadConfig,
|
|
5
|
+
import { loadConfig, safeError, validAgentId } from './config.js';
|
|
6
6
|
import { writeState } from './state.js';
|
|
7
7
|
const stateFile = (c) => path.join(c.dataDir, 'outbox-attention.json');
|
|
8
8
|
const validId = (v) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,150}$/.test(v);
|
|
9
9
|
const validToken = (v) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
10
10
|
const columns = `msg_id AS msgId,subject,attempts,created_at AS createdAt,updated_at AS failedAt,last_error AS error,version,
|
|
11
11
|
json_extract(envelope_json,'$.recipients[0]') AS peer`;
|
|
12
|
-
const
|
|
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
|
-
}
|
|
12
|
+
const saved = (c, agentId) => readOutboxDismissals(stateFile(c), agentId);
|
|
31
13
|
/** Metadata only. A dismissal never changes transport state or implies delivery. */
|
|
32
14
|
export async function readOutboxAttention(c, agentId, db) {
|
|
33
15
|
const state = await saved(c, agentId), ignored = new Map(state.records.map(r => [r.msgId, r.token]));
|
|
@@ -266,5 +266,11 @@ export function createDarwinAdapter(options = {}) {
|
|
|
266
266
|
{ id: "codex-desktop", installed: await hasApp(["Codex.app", "ChatGPT.app"], "com.openai.codex"), configPath: codexConfig, format: "toml" },
|
|
267
267
|
];
|
|
268
268
|
}
|
|
269
|
-
return { manager: "launchd", status, install, start, stop, detectClients
|
|
269
|
+
return { manager: "launchd", status, install, start, stop, detectClients,
|
|
270
|
+
async observeStore(ctx, pid) {
|
|
271
|
+
validate(ctx);
|
|
272
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
273
|
+
return null;
|
|
274
|
+
return observedStore(pid, ctx.storePath);
|
|
275
|
+
} };
|
|
270
276
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { constants } from "node:fs";
|
|
4
|
-
import { access, mkdir, open, readdir,
|
|
4
|
+
import { access, mkdir, open, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import path from "node:path";
|
|
@@ -76,8 +76,26 @@ export function createLinuxAdapter(options = {}) {
|
|
|
76
76
|
if (await readOwned(c) !== renderLinuxUnit(c))
|
|
77
77
|
throw new Error("service.profile-mismatch");
|
|
78
78
|
}
|
|
79
|
+
async function observeStore(c, pid) {
|
|
80
|
+
check(c);
|
|
81
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
82
|
+
return null;
|
|
83
|
+
try {
|
|
84
|
+
const expected = await realpath(c.storePath), fdDir = path.join(options.procDir ?? "/proc", String(pid), "fd");
|
|
85
|
+
for (const fd of await readdir(fdDir)) {
|
|
86
|
+
try {
|
|
87
|
+
if (await realpath(path.join(fdDir, fd)) === expected)
|
|
88
|
+
return expected;
|
|
89
|
+
}
|
|
90
|
+
catch { }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch { /* Inaccessible or exited processes are not liveness evidence. */ }
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
79
96
|
return {
|
|
80
97
|
manager: "systemd",
|
|
98
|
+
observeStore,
|
|
81
99
|
async status(c) {
|
|
82
100
|
check(c);
|
|
83
101
|
const result = { state: "unknown", manager: "systemd", since: null, pid: null,
|
|
@@ -98,21 +116,8 @@ export function createLinuxAdapter(options = {}) {
|
|
|
98
116
|
result.lastExitCode = /^\d+$/.test(values.ExecMainStatus ?? "") ? Number(values.ExecMainStatus) : null;
|
|
99
117
|
const started = Date.parse(values.ActiveEnterTimestamp ?? "");
|
|
100
118
|
result.since = Number.isFinite(started) ? new Date(started).toISOString() : null;
|
|
101
|
-
if (result.pid)
|
|
102
|
-
|
|
103
|
-
const expected = await realpath(c.storePath), fdDir = path.join(options.procDir ?? "/proc", String(result.pid), "fd");
|
|
104
|
-
for (const fd of await readdir(fdDir)) {
|
|
105
|
-
try {
|
|
106
|
-
if (await readlink(path.join(fdDir, fd)) === expected) {
|
|
107
|
-
result.observedStorePath = expected;
|
|
108
|
-
break;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
catch { }
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
catch { /* Inaccessible descriptors mean unverified, not guessed from config. */ }
|
|
115
|
-
}
|
|
119
|
+
if (result.pid)
|
|
120
|
+
result.observedStorePath = await observeStore(c, result.pid);
|
|
116
121
|
return result;
|
|
117
122
|
},
|
|
118
123
|
async install(c) {
|