@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,189 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
const paths = path.win32;
|
|
6
|
+
const fail = (reason) => { throw new Error(`service.${reason}`); };
|
|
7
|
+
/**
|
|
8
|
+
* murmur-svc ends a failure with exactly one footer line `murmur-svc: reason=<key>` (LF). Only that
|
|
9
|
+
* strict last line is trusted and only its key is kept, case preserved; the rest of stderr may carry
|
|
10
|
+
* paths or OS text and is never read. Anything else keeps the old generic code.
|
|
11
|
+
*/
|
|
12
|
+
export function helperReason(stderr) {
|
|
13
|
+
const key = /(?:^|\n)murmur-svc: reason=([A-Za-z][A-Za-z0-9.]{0,80})\n?$/.exec(stderr)?.[1];
|
|
14
|
+
return key && !key.endsWith('.') && !key.includes('..') ? `helper.${key}` : 'helper-command-failed';
|
|
15
|
+
}
|
|
16
|
+
const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
17
|
+
const natural = (v) => Number.isSafeInteger(v) && Number(v) >= 0;
|
|
18
|
+
const textOrNull = (v) => v === null || typeof v === 'string';
|
|
19
|
+
function absolute(value) {
|
|
20
|
+
if (typeof value !== 'string' || !paths.isAbsolute(value) || paths.normalize(value) !== value || /[\x00-\x1f\x7f]/.test(value))
|
|
21
|
+
fail('invalid-path');
|
|
22
|
+
}
|
|
23
|
+
function validate(c) {
|
|
24
|
+
for (const key of ['dataDir', 'configPath', 'storePath', 'repoRoot', 'nodePath', 'logDir'])
|
|
25
|
+
absolute(c[key]);
|
|
26
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,100}$/.test(c.serviceName))
|
|
27
|
+
fail('invalid-name');
|
|
28
|
+
if (c.configPath !== paths.join(c.dataDir, 'agent-config.json') || c.storePath !== paths.join(c.dataDir, 'murmur.db') || c.logDir !== paths.join(c.dataDir, 'logs'))
|
|
29
|
+
fail('conflicting-store-path');
|
|
30
|
+
}
|
|
31
|
+
const empty = (detail) => ({ state: 'unknown', manager: 'windows-service', since: null, pid: null,
|
|
32
|
+
lastExitCode: null, observedStorePath: null, restartCount: null, restartWindowMs: null, ...(detail ? { detail } : {}) });
|
|
33
|
+
const defaultRun = (file, args, options) => new Promise(resolve => {
|
|
34
|
+
execFile(file, args, { ...options, windowsHide: true, maxBuffer: 256 * 1024, encoding: 'utf8' }, (error, stdout, stderr) => {
|
|
35
|
+
resolve({ code: error ? typeof error.code === 'number' ? error.code : -1 : 0, stdout, stderr });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
// SCM mutations need a High (elevated administrator) or System integrity token. Without it the helper
|
|
39
|
+
// fails with a bare exit code, so a new user must be told to reopen the terminal as administrator.
|
|
40
|
+
const defaultElevated = () => new Promise(resolve => {
|
|
41
|
+
// By absolute path: a whoami.exe planted earlier in PATH must not decide this.
|
|
42
|
+
execFile(paths.join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'whoami.exe'), ['/groups', '/fo', 'csv', '/nh'], { windowsHide: true, encoding: 'utf8', timeout: 8000 }, (error, stdout) => {
|
|
43
|
+
resolve(error ? null : /"S-1-16-(12288|16384)"/.test(stdout));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
/** Native SCM helper is the only service observer and mutator. No shell, PID guessing or registry fallback. */
|
|
47
|
+
export function createWindowsAdapter(options = {}) {
|
|
48
|
+
const env = options.env ?? process.env, home = options.homeDir ?? homedir();
|
|
49
|
+
const run = options.run ?? defaultRun, canonicalize = options.canonicalize ?? realpath, elevated = options.elevated ?? defaultElevated;
|
|
50
|
+
const same = async (a, b) => (await canonicalize(a)) === (await canonicalize(b));
|
|
51
|
+
async function helper(c) {
|
|
52
|
+
const explicit = options.helperPath ?? env.MURMUR_SERVICE_BIN;
|
|
53
|
+
if (explicit !== undefined) {
|
|
54
|
+
absolute(explicit);
|
|
55
|
+
return explicit;
|
|
56
|
+
}
|
|
57
|
+
for (const file of [paths.join(c.repoRoot, 'bin', 'murmur-svc.exe'), paths.join(c.repoRoot, 'spikes', 'windows-service-go', 'murmur-svc.exe')]) {
|
|
58
|
+
try {
|
|
59
|
+
if ((await stat(file)).isFile())
|
|
60
|
+
return file;
|
|
61
|
+
}
|
|
62
|
+
catch { }
|
|
63
|
+
}
|
|
64
|
+
return fail('helper-not-installed');
|
|
65
|
+
}
|
|
66
|
+
function selectedEnvironment(c) {
|
|
67
|
+
const selected = {};
|
|
68
|
+
for (const name of ['SystemRoot', 'WINDIR', 'ProgramData', 'TEMP', 'TMP', 'USERPROFILE', 'LOCALAPPDATA', 'APPDATA', 'PATH']) {
|
|
69
|
+
const found = Object.keys(env).find(k => k.toLowerCase() === name.toLowerCase());
|
|
70
|
+
if (found && env[found] !== undefined)
|
|
71
|
+
selected[name] = env[found];
|
|
72
|
+
}
|
|
73
|
+
return { ...selected, DATA_DIR: c.dataDir, MURMUR_DATA_DIR: c.dataDir, MURMUR_NODE: c.nodePath,
|
|
74
|
+
MURMUR_ENTRY: paths.join(c.repoRoot, 'scripts', 'murmur-daemon.mjs'), MURMUR_WORKDIR: c.repoRoot,
|
|
75
|
+
MURMUR_SERVICE_NAME: c.serviceName, MURMUR_RESTARTS_PER_HOUR_LIMIT: '5' };
|
|
76
|
+
}
|
|
77
|
+
async function invoke(c, action) {
|
|
78
|
+
validate(c);
|
|
79
|
+
const result = await run(await helper(c), [action], { env: selectedEnvironment(c), timeout: action === 'status' ? 8000 : 55000 });
|
|
80
|
+
if (result.code !== 0)
|
|
81
|
+
fail(helperReason(result.stderr));
|
|
82
|
+
return result.stdout;
|
|
83
|
+
}
|
|
84
|
+
async function inspect(c) {
|
|
85
|
+
let v;
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(await invoke(c, 'status'));
|
|
88
|
+
if (!object(parsed))
|
|
89
|
+
fail('native-response-invalid');
|
|
90
|
+
v = parsed;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error instanceof SyntaxError)
|
|
94
|
+
fail('native-response-invalid');
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
const keys = ['schema', 'serviceName', 'profile', 'state', 'manager', 'pid', 'daemonPid', 'since', 'lastExitCode', 'lastFailureAt',
|
|
98
|
+
'observedStorePath', 'observedStoreUnknownReason', 'restartCount', 'restartWindowMs', 'restartsLastHour', 'restartsUnknownReason', 'restartsPerHourLimit'];
|
|
99
|
+
if (keys.some(k => !Object.hasOwn(v, k)) || v.schema !== 'murmur.windows-service/1' || v.serviceName !== c.serviceName
|
|
100
|
+
|| !['running', 'stopped', 'failed', 'unknown'].includes(v.state) || !['windows-service', 'none', 'foreign'].includes(v.manager)
|
|
101
|
+
|| !natural(v.pid) || !(v.daemonPid === null || natural(v.daemonPid) && v.daemonPid > 0)
|
|
102
|
+
|| !(v.lastExitCode === null || Number.isSafeInteger(v.lastExitCode))
|
|
103
|
+
|| !['since', 'lastFailureAt', 'observedStorePath', 'observedStoreUnknownReason', 'restartsUnknownReason'].every(k => textOrNull(v[k]))
|
|
104
|
+
|| !['since', 'lastFailureAt'].every(k => v[k] === null || Number.isFinite(Date.parse(v[k])))
|
|
105
|
+
|| v.restartsPerHourLimit !== 5)
|
|
106
|
+
fail('native-response-invalid');
|
|
107
|
+
if (!(v.restartCount === null && v.restartWindowMs === null || natural(v.restartCount) && natural(v.restartWindowMs) && v.restartWindowMs <= 3600000)
|
|
108
|
+
|| (v.restartWindowMs === 3600000 ? v.restartsLastHour !== v.restartCount : v.restartsLastHour !== null))
|
|
109
|
+
fail('native-response-invalid');
|
|
110
|
+
if (v.manager === 'none') {
|
|
111
|
+
if (v.state !== 'stopped' || v.profile !== null || v.pid !== 0 || v.daemonPid !== null || v.observedStorePath !== null || v.restartCount !== null)
|
|
112
|
+
fail('native-response-invalid');
|
|
113
|
+
return { snapshot: { ...empty('service.not-installed'), state: 'stopped' }, installed: false };
|
|
114
|
+
}
|
|
115
|
+
if (v.manager === 'foreign' || !object(v.profile))
|
|
116
|
+
fail('profile-unverified');
|
|
117
|
+
const expected = { dataDir: c.dataDir, node: c.nodePath, workDir: c.repoRoot, entry: paths.join(c.repoRoot, 'scripts', 'murmur-daemon.mjs') };
|
|
118
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
119
|
+
absolute(v.profile[key]);
|
|
120
|
+
if (!await same(v.profile[key], value))
|
|
121
|
+
fail('profile-mismatch');
|
|
122
|
+
}
|
|
123
|
+
if (v.profile.restartsPerHourLimit !== 5 || v.state === 'running' && v.pid === 0)
|
|
124
|
+
fail('native-response-invalid');
|
|
125
|
+
let observed = null;
|
|
126
|
+
if (v.observedStorePath !== null) {
|
|
127
|
+
absolute(v.observedStorePath);
|
|
128
|
+
if (v.daemonPid === null || !await same(v.observedStorePath, c.storePath))
|
|
129
|
+
fail('observed-store-mismatch');
|
|
130
|
+
observed = await canonicalize(v.observedStorePath);
|
|
131
|
+
}
|
|
132
|
+
return { installed: true, snapshot: { ...empty(), state: v.state, since: v.since, pid: v.daemonPid,
|
|
133
|
+
lastExitCode: v.lastExitCode, observedStorePath: observed, restartCount: v.restartCount, restartWindowMs: v.restartWindowMs } };
|
|
134
|
+
}
|
|
135
|
+
async function mutate(c, action) {
|
|
136
|
+
validate(c);
|
|
137
|
+
const before = await inspect(c);
|
|
138
|
+
if (action === 'install' && before.installed)
|
|
139
|
+
fail('already-installed');
|
|
140
|
+
if (action !== 'install' && !before.installed) {
|
|
141
|
+
if (action === 'stop' || action === 'uninstall')
|
|
142
|
+
return;
|
|
143
|
+
fail('not-installed');
|
|
144
|
+
}
|
|
145
|
+
// An unmeasurable level leaves the decision to SCM rather than refusing a real administrator.
|
|
146
|
+
if (await elevated() === false)
|
|
147
|
+
fail('elevation-required');
|
|
148
|
+
await invoke(c, action);
|
|
149
|
+
const after = await inspect(c);
|
|
150
|
+
if (action === 'uninstall' ? after.installed : !after.installed
|
|
151
|
+
|| (action === 'stop' ? after.snapshot.state !== 'stopped' : after.snapshot.state !== 'running' || after.snapshot.pid === null))
|
|
152
|
+
fail('action-unconfirmed');
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
manager: 'windows-service',
|
|
156
|
+
async status(c) {
|
|
157
|
+
try {
|
|
158
|
+
return (await inspect(c)).snapshot;
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
const reason = e instanceof Error && /^service\.(?:[a-z-]+|helper\.[A-Za-z][A-Za-z0-9.]*)$/.test(e.message) ? e.message : 'service.measurement-failed';
|
|
162
|
+
return empty(reason);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
install: c => mutate(c, 'install'), start: c => mutate(c, 'start'), stop: c => mutate(c, 'stop'), uninstall: c => mutate(c, 'uninstall'),
|
|
166
|
+
async detectClients() {
|
|
167
|
+
const rows = [];
|
|
168
|
+
for (const [id, command, configPath, format] of [
|
|
169
|
+
['claude-code', 'claude', env.CLAUDE_CONFIG_DIR ? null : paths.join(home, '.claude.json'), 'json'],
|
|
170
|
+
['codex-cli', 'codex', paths.join(env.CODEX_HOME || paths.join(home, '.codex'), 'config.toml'), 'toml'],
|
|
171
|
+
]) {
|
|
172
|
+
let installed = false;
|
|
173
|
+
for (const dir of (env.PATH ?? env.Path ?? '').split(';')) {
|
|
174
|
+
if (!paths.isAbsolute(dir))
|
|
175
|
+
continue;
|
|
176
|
+
for (const suffix of ['.exe', '.cmd', '.bat']) {
|
|
177
|
+
try {
|
|
178
|
+
if ((await stat(paths.join(dir, command + suffix))).isFile())
|
|
179
|
+
installed = true;
|
|
180
|
+
}
|
|
181
|
+
catch { }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
rows.push({ id, installed, configPath: configPath && paths.isAbsolute(configPath) ? configPath : null, format });
|
|
185
|
+
}
|
|
186
|
+
return rows;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type FileHandle } from 'node:fs/promises';
|
|
2
|
+
/** Protect a newly created, still-empty setup output before writing credentials. */
|
|
3
|
+
export declare function protectPrivateFile(file: string, handle: FileHandle): Promise<void>;
|
|
4
|
+
/** Existing Windows files keep their access policy, including intentional sandbox readers. */
|
|
5
|
+
export declare function preparePrivateReplacement(file: string, handle: FileHandle, previous: string): Promise<void>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { lstat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { sameClientFileIdentity } from './file-identity.js';
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
const protect = `
|
|
8
|
+
$ErrorActionPreference = 'Stop'
|
|
9
|
+
# Node can inherit PowerShell 7's module paths when launched by a CI runner or
|
|
10
|
+
# terminal. Only load modules belonging to this Windows PowerShell 5.1 process.
|
|
11
|
+
$env:PSModulePath = [System.IO.Path]::Combine($PSHOME, 'Modules')
|
|
12
|
+
$file = $env:MURMUR_SETUP_PRIVATE_FILE
|
|
13
|
+
$source = $env:MURMUR_SETUP_ACCESS_SOURCE
|
|
14
|
+
$section = [System.Security.AccessControl.AccessControlSections]::Access
|
|
15
|
+
function AccessPolicy($acl) {
|
|
16
|
+
$sddl = $acl.GetSecurityDescriptorSddlForm($section)
|
|
17
|
+
# Windows marks a copied DACL as auto-inherited even when its ACEs are unchanged.
|
|
18
|
+
# Ignore only that bookkeeping flag, retaining protection, order and every ACE.
|
|
19
|
+
return [regex]::Replace($sddl, '^D:(?:P|AI|AR)*', { param($match) $match.Value.Replace('AI', '') })
|
|
20
|
+
}
|
|
21
|
+
$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
22
|
+
# Match onboarding's new-profile policy: creator and the LocalSystem service.
|
|
23
|
+
# Existing replacements use their saved DACL below, including intentional readers.
|
|
24
|
+
$allowed = @($user, 'S-1-5-18') | Select-Object -Unique
|
|
25
|
+
$security = New-Object System.Security.AccessControl.FileSecurity
|
|
26
|
+
if ($source) {
|
|
27
|
+
$original = Get-Acl -LiteralPath $source
|
|
28
|
+
$expected = $original.GetSecurityDescriptorSddlForm($section)
|
|
29
|
+
$security.SetSecurityDescriptorSddlForm($expected, $section)
|
|
30
|
+
$expectedPolicy = AccessPolicy $original
|
|
31
|
+
} else {
|
|
32
|
+
$security.SetAccessRuleProtection($true, $false)
|
|
33
|
+
foreach ($id in $allowed) {
|
|
34
|
+
$sid = New-Object System.Security.Principal.SecurityIdentifier($id)
|
|
35
|
+
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'Allow')
|
|
36
|
+
$security.AddAccessRule($rule)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
Set-Acl -LiteralPath $file -AclObject $security
|
|
40
|
+
$actual = Get-Acl -LiteralPath $file
|
|
41
|
+
if ($source) {
|
|
42
|
+
if ((AccessPolicy $actual) -cne $expectedPolicy -or
|
|
43
|
+
(AccessPolicy (Get-Acl -LiteralPath $source)) -cne $expectedPolicy) { throw 'changed access policy' }
|
|
44
|
+
exit 0
|
|
45
|
+
}
|
|
46
|
+
if (!$actual.AreAccessRulesProtected) { throw 'unprotected ACL' }
|
|
47
|
+
$rules = @($actual.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))
|
|
48
|
+
if ($rules.Count -ne $allowed.Count) { throw 'unexpected ACL entries' }
|
|
49
|
+
foreach ($rule in $rules) {
|
|
50
|
+
if ($allowed -notcontains $rule.IdentityReference.Value -or $rule.AccessControlType -ne 'Allow' -or
|
|
51
|
+
$rule.FileSystemRights -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { throw 'unexpected ACL' }
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
async function prepareFile(file, handle, accessSource) {
|
|
55
|
+
if (process.platform !== 'win32')
|
|
56
|
+
return;
|
|
57
|
+
const opened = await handle.stat({ bigint: true });
|
|
58
|
+
const before = await lstat(file, { bigint: true });
|
|
59
|
+
if (!opened.isFile() || opened.size !== 0n || !before.isFile() || !sameClientFileIdentity(before, opened)) {
|
|
60
|
+
throw new Error('private-file.target-invalid');
|
|
61
|
+
}
|
|
62
|
+
const sourceBefore = accessSource ? await lstat(accessSource, { bigint: true }).catch(error => {
|
|
63
|
+
if (error.code === 'ENOENT')
|
|
64
|
+
return null;
|
|
65
|
+
throw error;
|
|
66
|
+
}) : null;
|
|
67
|
+
if (sourceBefore && (!sourceBefore.isFile() || sameClientFileIdentity(sourceBefore, opened))) {
|
|
68
|
+
throw new Error('private-file.access-source-invalid');
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
// Cold Windows PowerShell 5.1 startup (including antivirus checks) can exceed
|
|
72
|
+
// ten seconds. This ceiling bounds a hung ACL command, not its normal path.
|
|
73
|
+
await exec(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(protect, 'utf16le').toString('base64')], { env: { ...process.env, MURMUR_SETUP_PRIVATE_FILE: file, MURMUR_SETUP_ACCESS_SOURCE: sourceBefore ? accessSource : '' },
|
|
74
|
+
windowsHide: true, timeout: 60_000, maxBuffer: 64 * 1024 });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
// Keep paths, command text and arbitrary PowerShell stderr out of diagnostics.
|
|
78
|
+
const failure = error;
|
|
79
|
+
const rejection = ['changed access policy', 'unprotected ACL', 'unexpected ACL entries', 'unexpected ACL']
|
|
80
|
+
.find(reason => failure.stderr?.includes(reason));
|
|
81
|
+
const reason = failure.killed ? 'timeout' : failure.code === 'ENOENT' ? 'powershell-unavailable'
|
|
82
|
+
: rejection?.replaceAll(' ', '-') ?? 'command-failed';
|
|
83
|
+
throw new Error(`private-file.windows-acl-failed:${reason}`);
|
|
84
|
+
}
|
|
85
|
+
const after = await lstat(file, { bigint: true });
|
|
86
|
+
if (!after.isFile() || !sameClientFileIdentity(after, opened))
|
|
87
|
+
throw new Error('private-file.target-changed');
|
|
88
|
+
if (sourceBefore) {
|
|
89
|
+
const sourceAfter = await lstat(accessSource, { bigint: true });
|
|
90
|
+
if (!sourceAfter.isFile() || !sameClientFileIdentity(sourceBefore, sourceAfter))
|
|
91
|
+
throw new Error('private-file.access-source-changed');
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Protect a newly created, still-empty setup output before writing credentials. */
|
|
95
|
+
export async function protectPrivateFile(file, handle) {
|
|
96
|
+
return prepareFile(file, handle);
|
|
97
|
+
}
|
|
98
|
+
/** Existing Windows files keep their access policy, including intentional sandbox readers. */
|
|
99
|
+
export async function preparePrivateReplacement(file, handle, previous) {
|
|
100
|
+
return prepareFile(file, handle, previous);
|
|
101
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ServiceContext } from './types.js';
|
|
2
|
+
/** Preparing instructions never sends a message or writes any profile state. */
|
|
3
|
+
export declare function prepareReplyTest(context: ServiceContext, peerId: string): Promise<{
|
|
4
|
+
schema: string;
|
|
5
|
+
agentId: string;
|
|
6
|
+
peerId: string;
|
|
7
|
+
conversationId: string;
|
|
8
|
+
token: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
expiresAt: string;
|
|
11
|
+
requestText: string;
|
|
12
|
+
expectedReply: string;
|
|
13
|
+
}>;
|
|
14
|
+
/** Inspect only this test's durable rows; do not consume inbox state or poll NATS. */
|
|
15
|
+
export declare function checkReplyTest(context: ServiceContext, token: string): Promise<{
|
|
16
|
+
schema: string;
|
|
17
|
+
generatedAt: string;
|
|
18
|
+
agentId: string;
|
|
19
|
+
peerId: string;
|
|
20
|
+
conversationId: string;
|
|
21
|
+
state: string;
|
|
22
|
+
requestMsgId: any;
|
|
23
|
+
replyMsgId: any;
|
|
24
|
+
receivedAt: any;
|
|
25
|
+
}>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { loadConfig, validAgentId } from './config.js';
|
|
4
|
+
import { pairFingerprint } from './status.js';
|
|
5
|
+
const duration = 15 * 60 * 1000;
|
|
6
|
+
const conversation = (c) => `murmur:setup:${c.nonce}`;
|
|
7
|
+
const reply = (c) => `MURMUR-SETUP-REPLY ${c.nonce}`;
|
|
8
|
+
const request = (c) => `Murmur connection test from ${c.agentId}. Use murmur_send to reply to ${c.agentId} in this same conversation with exactly this line: ${reply(c)}`;
|
|
9
|
+
// Agent identities/signatures may surround the protocol line, but a substring
|
|
10
|
+
// inside an explanation or blockquote is not a completed request/reply.
|
|
11
|
+
const hasProtocolLine = (text, expected) => text.split(/\r?\n/).some(line => line.trim() === expected);
|
|
12
|
+
/** Preparing instructions never sends a message or writes any profile state. */
|
|
13
|
+
export async function prepareReplyTest(context, peerId) {
|
|
14
|
+
const config = await loadConfig(context);
|
|
15
|
+
if (!validAgentId(peerId) || !config.peers[peerId] || peerId === config.agentId)
|
|
16
|
+
throw new Error('reply-test.peer-invalid');
|
|
17
|
+
const challenge = { version: 1, agentId: config.agentId, peerId, fingerprint: pairFingerprint(config, peerId),
|
|
18
|
+
nonce: randomBytes(24).toString('hex'), createdAt: new Date().toISOString() };
|
|
19
|
+
return { schema: 'murmur.reply-test-plan/1', agentId: config.agentId, peerId, conversationId: conversation(challenge),
|
|
20
|
+
token: Buffer.from(JSON.stringify(challenge)).toString('base64url'), createdAt: challenge.createdAt,
|
|
21
|
+
expiresAt: new Date(Date.parse(challenge.createdAt) + duration).toISOString(),
|
|
22
|
+
requestText: request(challenge), expectedReply: reply(challenge) };
|
|
23
|
+
}
|
|
24
|
+
/** Inspect only this test's durable rows; do not consume inbox state or poll NATS. */
|
|
25
|
+
export async function checkReplyTest(context, token) {
|
|
26
|
+
if (token.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(token))
|
|
27
|
+
throw new Error('reply-test.token-invalid');
|
|
28
|
+
let test;
|
|
29
|
+
try {
|
|
30
|
+
test = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error('reply-test.token-invalid');
|
|
34
|
+
}
|
|
35
|
+
const now = Date.now(), start = Date.parse(test?.createdAt), end = start + duration;
|
|
36
|
+
if (test?.version !== 1 || !validAgentId(test.agentId) || !validAgentId(test.peerId)
|
|
37
|
+
|| typeof test.nonce !== 'string' || !/^[a-f0-9]{48}$/.test(test.nonce)
|
|
38
|
+
|| typeof test.fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(test.fingerprint)
|
|
39
|
+
|| !Number.isFinite(start) || start > now + 5000)
|
|
40
|
+
throw new Error('reply-test.token-invalid');
|
|
41
|
+
const config = await loadConfig(context), peer = config.peers[test.peerId];
|
|
42
|
+
if (config.agentId !== test.agentId || !peer || pairFingerprint(config, test.peerId) !== test.fingerprint)
|
|
43
|
+
throw new Error('reply-test.profile-changed');
|
|
44
|
+
const db = new DatabaseSync(context.storePath, { readOnly: true });
|
|
45
|
+
try {
|
|
46
|
+
db.exec('PRAGMA busy_timeout=1000; BEGIN');
|
|
47
|
+
const sent = db.prepare(`SELECT msg_id AS msgId, created_at AS createdAt, text, channel_id AS channelId,
|
|
48
|
+
sender_member_id AS senderMemberId, addressee_member_id AS addresseeMemberId FROM local_messages
|
|
49
|
+
WHERE direction='outbound' AND sender=? AND conversation_id=? ORDER BY rowid DESC LIMIT 100`)
|
|
50
|
+
.all(test.agentId, conversation(test));
|
|
51
|
+
const received = db.prepare(`SELECT msg_id AS msgId, created_at AS createdAt, text, channel_id AS channelId,
|
|
52
|
+
sender_member_id AS senderMemberId, addressee_member_id AS addresseeMemberId FROM local_messages
|
|
53
|
+
WHERE direction='inbound' AND sender=? AND conversation_id=?
|
|
54
|
+
ORDER BY rowid DESC LIMIT 100`).all(test.peerId, conversation(test));
|
|
55
|
+
db.exec('COMMIT');
|
|
56
|
+
const inWindow = (row) => {
|
|
57
|
+
const at = Date.parse(row.createdAt);
|
|
58
|
+
return Number.isFinite(at) && at >= start - 5000 && at <= Math.min(end, now + 5000);
|
|
59
|
+
};
|
|
60
|
+
const routing = (row, inbound) => (row.channelId ?? null) === (peer.channelId ?? null)
|
|
61
|
+
&& (row.senderMemberId ?? null) === ((inbound ? peer.memberId : config.memberId) ?? null)
|
|
62
|
+
&& (row.addresseeMemberId ?? null) === ((inbound ? config.memberId : peer.memberId) ?? null);
|
|
63
|
+
const outbound = sent.find(row => hasProtocolLine(row.text, request(test)) && inWindow(row) && routing(row, false));
|
|
64
|
+
const inbound = outbound && received.find(row => inWindow(row) && routing(row, true)
|
|
65
|
+
&& hasProtocolLine(row.text, reply(test))
|
|
66
|
+
&& Date.parse(row.createdAt) >= Date.parse(outbound.createdAt) - 5000);
|
|
67
|
+
return { schema: 'murmur.reply-test/1', generatedAt: new Date(now).toISOString(), agentId: config.agentId,
|
|
68
|
+
peerId: test.peerId, conversationId: conversation(test),
|
|
69
|
+
state: inbound ? 'replied' : now > end ? 'expired' : outbound ? 'waiting' : 'not-sent',
|
|
70
|
+
requestMsgId: outbound?.msgId ?? null, replyMsgId: inbound?.msgId ?? null, receivedAt: inbound?.createdAt ?? null };
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
db.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { preparePrivateReplacement } from "./private-file.js";
|
|
4
|
+
/** Reuse the runtime's existing private-state writer; setup ships with that runtime. */
|
|
5
|
+
export async function writeState(c, file, value) {
|
|
6
|
+
if (path.dirname(file) !== c.dataDir)
|
|
7
|
+
throw new Error("state.outside-data-dir");
|
|
8
|
+
const helpers = await import(pathToFileURL(path.join(c.repoRoot, "scripts", "secure-state.mjs")).href);
|
|
9
|
+
await helpers.writePrivateJson(file, value, {
|
|
10
|
+
beforeWrite: (temporary, handle) => preparePrivateReplacement(temporary, handle, file),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { statusVerdict } from './verdict.js';
|
|
2
|
+
const natural = (value) => Number.isSafeInteger(value) && Number(value) >= 0;
|
|
3
|
+
const safeCode = (value) => /^[a-z][a-z0-9.-]{0,80}$/.test(value) ? value : 'status.invalid';
|
|
4
|
+
/**
|
|
5
|
+
* Render one untrusted-data-free segment for prompts, tmux and Claude Code.
|
|
6
|
+
* Message bodies, peer IDs and runtime errors are deliberately never copied.
|
|
7
|
+
*/
|
|
8
|
+
export function renderStatusLine(status, now = Date.now()) {
|
|
9
|
+
const verdict = statusVerdict(status, now);
|
|
10
|
+
const unreadValue = status && typeof status === 'object'
|
|
11
|
+
? status.inbox?.unread
|
|
12
|
+
: null;
|
|
13
|
+
const unread = natural(unreadValue) && unreadValue > 0 ? `Murmur: ${unreadValue} unread` : '';
|
|
14
|
+
if (verdict.level === 'green')
|
|
15
|
+
return unread;
|
|
16
|
+
const label = verdict.level === 'red' ? 'error' : verdict.level === 'yellow' ? 'warning' : 'unknown';
|
|
17
|
+
const health = `Murmur: ${label} (${safeCode(verdict.code)})`;
|
|
18
|
+
return unread ? `${unread} | ${health}` : health;
|
|
19
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { type AgentConfig } from "./config.js";
|
|
2
|
+
import type { PlatformAdapter, ServiceContext } from "./types.js";
|
|
3
|
+
export type Measurement = {
|
|
4
|
+
measuredAt: string | null;
|
|
5
|
+
unknownReason: string | null;
|
|
6
|
+
};
|
|
7
|
+
export declare const pairFingerprint: (config: AgentConfig, peerId: string) => string;
|
|
8
|
+
export declare function validPairProof(proof: any, config: AgentConfig, peerId: string, now: number): boolean;
|
|
9
|
+
export interface StatusOptions {
|
|
10
|
+
context: ServiceContext;
|
|
11
|
+
adapter: PlatformAdapter;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
}
|
|
14
|
+
export declare function readStatus({ context: c, adapter, now }: StatusOptions): Promise<{
|
|
15
|
+
schema: "murmur.status/1";
|
|
16
|
+
generatedAt: string;
|
|
17
|
+
agentId: string | null;
|
|
18
|
+
serviceName: string;
|
|
19
|
+
service: {
|
|
20
|
+
lastFailureAt: null;
|
|
21
|
+
restartsLastHour: number | null;
|
|
22
|
+
restartFailureThreshold: number;
|
|
23
|
+
unknownReason: string | null;
|
|
24
|
+
measurements: {
|
|
25
|
+
manager: Measurement;
|
|
26
|
+
history: Measurement;
|
|
27
|
+
};
|
|
28
|
+
state: "running" | "stopped" | "failed" | "unknown";
|
|
29
|
+
manager: import("./types.js").ServiceManager;
|
|
30
|
+
since: string | null;
|
|
31
|
+
pid: number | null;
|
|
32
|
+
lastExitCode: number | null;
|
|
33
|
+
observedStorePath: string | null;
|
|
34
|
+
restartCount: number | null;
|
|
35
|
+
restartWindowMs: number | null;
|
|
36
|
+
detail?: string;
|
|
37
|
+
};
|
|
38
|
+
broker: {
|
|
39
|
+
url: string | null;
|
|
40
|
+
state: any;
|
|
41
|
+
connectedAt: any;
|
|
42
|
+
lastError: any;
|
|
43
|
+
lastErrorAt: any;
|
|
44
|
+
unknownReason: string | null;
|
|
45
|
+
measurements: {
|
|
46
|
+
runtime: Measurement;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
peers: {
|
|
50
|
+
list: Array<{
|
|
51
|
+
agentId: string;
|
|
52
|
+
paired: boolean | null;
|
|
53
|
+
lastInboundAt: string | null;
|
|
54
|
+
lastOutboundAt: string | null;
|
|
55
|
+
}> | null;
|
|
56
|
+
unknownReason: string | null;
|
|
57
|
+
measurements: Record<string, Measurement>;
|
|
58
|
+
};
|
|
59
|
+
inbox: {
|
|
60
|
+
unread: number | null;
|
|
61
|
+
total: number | null;
|
|
62
|
+
lastAt: string | null;
|
|
63
|
+
unknownReason: string | null;
|
|
64
|
+
measurements: {
|
|
65
|
+
store: Measurement;
|
|
66
|
+
cursor: Measurement;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
deliveries: Record<string, unknown>[] | null;
|
|
70
|
+
outbox: {
|
|
71
|
+
attention: {
|
|
72
|
+
schema: "murmur.outbox-attention/1";
|
|
73
|
+
total: number;
|
|
74
|
+
pending: number;
|
|
75
|
+
dismissed: number;
|
|
76
|
+
items: {
|
|
77
|
+
msgId: string;
|
|
78
|
+
peer: string | null;
|
|
79
|
+
createdAt: string;
|
|
80
|
+
failedAt: string;
|
|
81
|
+
reason: string;
|
|
82
|
+
attempts: number;
|
|
83
|
+
token: string;
|
|
84
|
+
dismissed: boolean;
|
|
85
|
+
}[];
|
|
86
|
+
truncated: boolean;
|
|
87
|
+
unknownReason: null;
|
|
88
|
+
} | {
|
|
89
|
+
unknownReason: string;
|
|
90
|
+
};
|
|
91
|
+
queue: {
|
|
92
|
+
pending: number | null;
|
|
93
|
+
inflight: number | null;
|
|
94
|
+
delivered: number | null;
|
|
95
|
+
failed: number | null;
|
|
96
|
+
dlq: number | null;
|
|
97
|
+
oldestPendingAt: string | null;
|
|
98
|
+
unknownReason: string | null;
|
|
99
|
+
};
|
|
100
|
+
faults: {
|
|
101
|
+
lastError: string | null;
|
|
102
|
+
lastErrorAt: string | null;
|
|
103
|
+
unknownReason: string | null;
|
|
104
|
+
source: string;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
wake: {
|
|
108
|
+
config: {
|
|
109
|
+
enabled: boolean | null;
|
|
110
|
+
mode: "none" | "hook" | "monitor" | null;
|
|
111
|
+
responder: string | null;
|
|
112
|
+
unknownReason: string | null;
|
|
113
|
+
};
|
|
114
|
+
effective: {
|
|
115
|
+
enabled: any;
|
|
116
|
+
needsRestart: boolean | null;
|
|
117
|
+
observedAt: string | null;
|
|
118
|
+
unknownReason: string | null;
|
|
119
|
+
};
|
|
120
|
+
delivery: {
|
|
121
|
+
pendingUndelivered: number | null;
|
|
122
|
+
storedOnly: number | null;
|
|
123
|
+
lastDeliveredAt: string | null;
|
|
124
|
+
unknownReason: string | null;
|
|
125
|
+
};
|
|
126
|
+
faults: {
|
|
127
|
+
lastFault: string | null;
|
|
128
|
+
lastFaultAt: any;
|
|
129
|
+
unknownReason: string | null;
|
|
130
|
+
measurements: {
|
|
131
|
+
store: Measurement;
|
|
132
|
+
runtime: Measurement;
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
};
|
|
136
|
+
}>;
|
|
137
|
+
export declare function configuredWakeMode(config: AgentConfig): "hook" | "monitor" | "none";
|