@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.
Files changed (40) hide show
  1. package/README.md +37 -12
  2. package/package.json +2 -1
  3. package/runtime/bin/murmur-svc.exe +0 -0
  4. package/runtime/npm-runtime-manifest.json +97 -61
  5. package/runtime/package.json +1 -1
  6. package/runtime/packages/setup/bin/murmur.mjs +5 -3
  7. package/runtime/packages/setup/dist/src/cli.d.ts +1 -1
  8. package/runtime/packages/setup/dist/src/cli.js +49 -9
  9. package/runtime/packages/setup/dist/src/clients.js +8 -2
  10. package/runtime/packages/setup/dist/src/commands.d.ts +3 -1
  11. package/runtime/packages/setup/dist/src/commands.js +17 -6
  12. package/runtime/packages/setup/dist/src/config.d.ts +2 -0
  13. package/runtime/packages/setup/dist/src/config.js +61 -0
  14. package/runtime/packages/setup/dist/src/doctor.d.ts +12 -0
  15. package/runtime/packages/setup/dist/src/doctor.js +19 -2
  16. package/runtime/packages/setup/dist/src/invitation-server.d.ts +2 -0
  17. package/runtime/packages/setup/dist/src/invitation-server.js +45 -0
  18. package/runtime/packages/setup/dist/src/message-status.d.ts +4 -0
  19. package/runtime/packages/setup/dist/src/message-status.js +10 -0
  20. package/runtime/packages/setup/dist/src/onboarding.d.ts +19 -6
  21. package/runtime/packages/setup/dist/src/onboarding.js +46 -22
  22. package/runtime/packages/setup/dist/src/outbox-attention.js +3 -21
  23. package/runtime/packages/setup/dist/src/platform/darwin.js +7 -1
  24. package/runtime/packages/setup/dist/src/platform/linux.js +21 -16
  25. package/runtime/packages/setup/dist/src/platform/windows.js +67 -3
  26. package/runtime/packages/setup/dist/src/status.d.ts +17 -2
  27. package/runtime/packages/setup/dist/src/status.js +78 -18
  28. package/runtime/packages/setup/dist/src/types.d.ts +11 -1
  29. package/runtime/packages/setup/dist/src/verdict.js +4 -2
  30. package/runtime/packages/setup/dist/src/wake-attention.d.ts +11 -0
  31. package/runtime/packages/setup/dist/src/wake-attention.js +44 -0
  32. package/runtime/packages/setup/package.json +1 -1
  33. package/runtime/scripts/codex-app-server-wake.mjs +169 -32
  34. package/runtime/scripts/daemon-contacts.mjs +56 -0
  35. package/runtime/scripts/daemon-flush-tick.mjs +25 -0
  36. package/runtime/scripts/daemon-link-watch.mjs +60 -0
  37. package/runtime/scripts/daemon-observation.mjs +74 -18
  38. package/runtime/scripts/murmur-daemon.mjs +45 -31
  39. package/runtime/scripts/wake-drain-claude.mjs +69 -32
  40. 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 adapter.stop(context);
57
- await adapter.start(context);
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, unread: cursor === null ? null : Number(rowid) > cursor })) };
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 !== 'running')
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
- return { schema: 'murmur.doctor/1', generatedAt, agentId: config?.agentId ?? null, stages, summary: { worst, failedStage } };
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,2 @@
1
+ /** Address policy only: Invitation creation never connects or claims reachability. */
2
+ export declare function invitationServerAddress(value: string): string;
@@ -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: string): Promise<{
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: string;
22
- replyOut: string;
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: string): Promise<{
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
- async function outputBlob(file, value, prefix, beforeWrite) {
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(prefix + Buffer.from(JSON.stringify(value)).toString('base64') + '\n');
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(file, prefix, type) {
189
- const text = await privateText(file, `${type}-file`);
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
- if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || Buffer.from(encoded, 'base64').toString('base64') !== encoded)
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
- await validateOutput(c, outFile);
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
- 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.
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 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.' };
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
- await validateOutput(c, options.replyOut);
253
- const incoming = await readBlob(options.inviteFile, 'MURMUR:', 'invite');
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
- 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 };
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, 'MURMUR-REPLY:', 'reply');
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: true };
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 { createHash } from 'node:crypto';
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, readJson, safeError, validAgentId } from './config.js';
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 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
- }
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, readlink, realpath, rename, stat, unlink, writeFile } from "node:fs/promises";
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
- try {
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) {