@getmarrow/install 0.1.65 → 0.1.67
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 +81 -22
- package/package.json +1 -1
- package/src/controller-manager.js +152 -6
- package/src/enforcement-client.js +6 -3
- package/src/first-hour.js +17 -3
- package/src/governance-sidecar.js +6 -0
- package/src/governed-runner.js +413 -90
- package/src/hermes-config.js +568 -0
- package/src/installer.js +767 -376
- package/src/owner-env.js +134 -0
package/src/installer.js
CHANGED
|
@@ -4,16 +4,28 @@ const os = require('node:os');
|
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
5
|
const { spawnSync } = require('node:child_process');
|
|
6
6
|
const { version: INSTALLER_ADAPTER_VERSION } = require('../package.json');
|
|
7
|
-
const {
|
|
7
|
+
const {
|
|
8
|
+
controllerStatus,
|
|
9
|
+
controllerSupportedPlatform,
|
|
10
|
+
ensureCurrentGovernanceController,
|
|
11
|
+
} = require('./controller-manager');
|
|
8
12
|
const { firstCapturePath, harnessReloadPlan } = require('./first-hour');
|
|
9
13
|
const { evidence: localControlEvidence } = require('./control-state');
|
|
14
|
+
const {
|
|
15
|
+
hermesEnvHasKey,
|
|
16
|
+
hermesPaths,
|
|
17
|
+
manualBlock: hermesManualBlock,
|
|
18
|
+
marrowEnvKeyNames: hermesMarrowEnvKeyNames,
|
|
19
|
+
planHermesMcpConfig,
|
|
20
|
+
} = require('./hermes-config');
|
|
21
|
+
const { ensureOwnerApiKey, readOwnerApiKey } = require('./owner-env');
|
|
10
22
|
|
|
11
23
|
const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
|
|
12
24
|
const MARROW_BLOCK_START = '<!-- marrow:passive-start -->';
|
|
13
25
|
const MARROW_BLOCK_END = '<!-- marrow:passive-end -->';
|
|
14
|
-
const MCP_ADAPTER_VERSION = '3.9.
|
|
15
|
-
const MCP_ADAPTER_SOURCE_SHA = '
|
|
16
|
-
const MCP_ADAPTER_INTEGRITY = 'sha512-
|
|
26
|
+
const MCP_ADAPTER_VERSION = '3.9.98';
|
|
27
|
+
const MCP_ADAPTER_SOURCE_SHA = 'e40d3cb40479456fd937bce0b9488eb0c3f10863';
|
|
28
|
+
const MCP_ADAPTER_INTEGRITY = 'sha512-AmDT3afwdm7+Dc555zDs+yGIG4RyC/YbaQm+9O1mThlC6g/9EujTr7y7UvRMEtYDvVWAAaG6CFM7/u/ytjKhwQ==';
|
|
17
29
|
const SDK_ADAPTER_VERSION = '3.7.64';
|
|
18
30
|
const SDK_ADAPTER_INTEGRITY = 'sha512-8qJj/8ouHEz1NnZkmujtFxUm/fWldqR/rHv62/sqabaxT0H90xCCHxodLOkV0SVxDscAtnahioZakqkeGNUwyA==';
|
|
19
31
|
const SDK_ADAPTER_TARBALL = `https://registry.npmjs.org/@getmarrow/sdk/-/sdk-${SDK_ADAPTER_VERSION}.tgz`;
|
|
@@ -229,10 +241,13 @@ function alignMcpRecommendationVersions(value, targetVersion, key = '', verifica
|
|
|
229
241
|
alignMcpRecommendationVersions(entryValue, targetVersion, entryKey, verificationAction),
|
|
230
242
|
]));
|
|
231
243
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const
|
|
244
|
+
// Claude Code hooks use the Claude-specific entrypoints that MCP setup also writes. MCP labels a
|
|
245
|
+
// hook by its entrypoint, and the bare spellings resolve to the generic mcp-client harness.
|
|
246
|
+
// Writing the same spelling as `marrow-mcp setup` means neither writer rewrites the other.
|
|
247
|
+
const MCP_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-context-hook`;
|
|
248
|
+
const MCP_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-pre-action-hook`;
|
|
249
|
+
const MCP_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-hook`;
|
|
250
|
+
const MCP_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-session-hook`;
|
|
236
251
|
const CODEX_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-context-hook`;
|
|
237
252
|
const CODEX_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-pre-action-hook`;
|
|
238
253
|
const CODEX_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-hook`;
|
|
@@ -757,6 +772,7 @@ function parseArgs(argv, env = process.env) {
|
|
|
757
772
|
options.doctor = true;
|
|
758
773
|
}
|
|
759
774
|
else if (arg === '--json') options.json = true;
|
|
775
|
+
else if (arg === '--verbose') options.verbose = true;
|
|
760
776
|
else if (arg === '--no-self-test') {
|
|
761
777
|
explicitOperation = true;
|
|
762
778
|
options.selfTest = false;
|
|
@@ -844,6 +860,9 @@ Options:
|
|
|
844
860
|
--agent-id <id> Agent/fleet id for self-test headers
|
|
845
861
|
--no-controller Do not start the local background controller during install/repair
|
|
846
862
|
--no-self-test Skip API smoke/self-test
|
|
863
|
+
--verbose Print the full report instead of the one-line summary and log file
|
|
864
|
+
|
|
865
|
+
The API key comes from MARROW_API_KEY, or from the owner-only ~/.marrow/env when unset.
|
|
847
866
|
|
|
848
867
|
Environment:
|
|
849
868
|
MARROW_TOOL_PROFILE Leave unset for primary (17 tools), or explicitly set primary, core, or full.
|
|
@@ -864,10 +883,74 @@ function detectedClient(detection) {
|
|
|
864
883
|
if (detection.cline) return 'cline';
|
|
865
884
|
if (detection.windsurf) return 'windsurf';
|
|
866
885
|
if (detection.gemini) return 'gemini';
|
|
886
|
+
if (detection.codexProject) return 'codex';
|
|
887
|
+
// Home-level harnesses rank below project markers; a shared AGENTS.md is a weaker signal.
|
|
888
|
+
if (detection.hermes) return 'hermes';
|
|
867
889
|
if (detection.codex) return 'codex';
|
|
868
890
|
return 'custom';
|
|
869
891
|
}
|
|
870
892
|
|
|
893
|
+
function executableOnPath(name, env = process.env) {
|
|
894
|
+
const extensions = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
|
|
895
|
+
for (const directory of String(env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
896
|
+
for (const extension of extensions) {
|
|
897
|
+
try {
|
|
898
|
+
const stat = fs.statSync(path.join(directory, `${name}${extension}`));
|
|
899
|
+
if (stat.isFile() && (process.platform === 'win32' || (stat.mode & 0o111) !== 0)) return true;
|
|
900
|
+
} catch {
|
|
901
|
+
// Missing or unreadable PATH entries are not a detection signal.
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Harnesses installed for the user rather than in the project. Each detector is one bounded
|
|
909
|
+
// check; the list is extended one harness at a time. MARROW_CLIENT still overrides detection.
|
|
910
|
+
const HOME_HARNESS_DETECTORS = Object.freeze([
|
|
911
|
+
Object.freeze({
|
|
912
|
+
client: 'hermes',
|
|
913
|
+
detect: ({ home, env }) => exists(hermesPaths(home, env).config) || executableOnPath('hermes', env),
|
|
914
|
+
}),
|
|
915
|
+
]);
|
|
916
|
+
|
|
917
|
+
function detectHomeHarnesses(home, env) {
|
|
918
|
+
return Object.fromEntries(HOME_HARNESS_DETECTORS.map((detector) => {
|
|
919
|
+
try {
|
|
920
|
+
return [detector.client, Boolean(detector.detect({ home, env }))];
|
|
921
|
+
} catch {
|
|
922
|
+
return [detector.client, false];
|
|
923
|
+
}
|
|
924
|
+
}));
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// AGENTS.md is shared by many agents, and earlier installers created it with only the Marrow
|
|
928
|
+
// block, which then made every later run treat the project as Codex. Only owner content or a
|
|
929
|
+
// .codex directory holding more than Marrow's own hooks file is a Codex signal.
|
|
930
|
+
function agentsMdHasOwnerContent(filePath) {
|
|
931
|
+
const raw = safeRead(filePath);
|
|
932
|
+
if (!raw.trim()) return false;
|
|
933
|
+
const block = marrowManagedBlockInText(raw);
|
|
934
|
+
return (block ? raw.replace(block, '') : raw).trim().length > 0;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function codexDirectoryIsOwnerConfigured(directory) {
|
|
938
|
+
let entries;
|
|
939
|
+
try {
|
|
940
|
+
entries = fs.readdirSync(directory);
|
|
941
|
+
} catch {
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
// Marrow only ever creates .codex/hooks.json, so any other content, or an empty directory the
|
|
945
|
+
// owner or Codex created, is an owner signal.
|
|
946
|
+
if (entries.length === 0 || entries.some((entry) => entry !== 'hooks.json')) return true;
|
|
947
|
+
const settings = safeJsonObject(path.join(directory, 'hooks.json'));
|
|
948
|
+
const hooks = settings.hooks && typeof settings.hooks === 'object' ? Object.values(settings.hooks) : [];
|
|
949
|
+
return hooks.some((entries) => Array.isArray(entries) && entries.some((entry) => (
|
|
950
|
+
Array.isArray(entry?.hooks) && entry.hooks.some((hook) => !marrowHookSubcommand(hook?.command))
|
|
951
|
+
)));
|
|
952
|
+
}
|
|
953
|
+
|
|
871
954
|
function exists(filePath) {
|
|
872
955
|
return fs.existsSync(filePath);
|
|
873
956
|
}
|
|
@@ -948,7 +1031,12 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
|
|
|
948
1031
|
passiveRuntime: path.join(root, '.marrow', 'passive-runtime.mjs'),
|
|
949
1032
|
passiveEnv: path.join(root, '.marrow', 'env.example'),
|
|
950
1033
|
openclawJson: findUp(root, ['openclaw.json'], 4) || path.join(home, '.openclaw', 'openclaw.json'),
|
|
1034
|
+
hermesHome: hermesPaths(home, env).home,
|
|
1035
|
+
hermesConfig: hermesPaths(home, env).config,
|
|
1036
|
+
hermesEnv: hermesPaths(home, env).env,
|
|
951
1037
|
};
|
|
1038
|
+
const homeHarnesses = detectHomeHarnesses(home, env);
|
|
1039
|
+
const codexProject = codexDirectoryIsOwnerConfigured(path.join(root, '.codex'));
|
|
952
1040
|
|
|
953
1041
|
return {
|
|
954
1042
|
root,
|
|
@@ -962,7 +1050,10 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
|
|
|
962
1050
|
windsurf: exists(path.join(root, '.windsurf')),
|
|
963
1051
|
gemini: exists(path.join(root, '.gemini')),
|
|
964
1052
|
grok: exists(path.join(home, '.grok')) || exists(path.join(root, '.grok')),
|
|
965
|
-
|
|
1053
|
+
codexProject,
|
|
1054
|
+
codex: codexProject || agentsMdHasOwnerContent(paths.agentsMd),
|
|
1055
|
+
hermes: homeHarnesses.hermes === true,
|
|
1056
|
+
hermesConfig: exists(paths.hermesConfig),
|
|
966
1057
|
mcpConfig: exists(paths.mcpJson) || exists(paths.cursorMcp) || exists(paths.claudeSettings),
|
|
967
1058
|
openclaw: exists(paths.openclawJson) || Boolean(env.OPENCLAW_HOME || env.OPENCLAW_AGENT_ID),
|
|
968
1059
|
};
|
|
@@ -1024,8 +1115,6 @@ function findLikelyEnvFiles(detection, env = process.env) {
|
|
|
1024
1115
|
path.join(detection.root, '.marrow', 'env'),
|
|
1025
1116
|
path.join(detection.root, '.marrow', 'env.local'),
|
|
1026
1117
|
path.join(home, '.marrow', 'env'),
|
|
1027
|
-
path.join(home, '.openclaw', 'credentials', 'marrow-mcp.env'),
|
|
1028
|
-
path.join(home, '.openclaw', 'gateway.systemd.env'),
|
|
1029
1118
|
];
|
|
1030
1119
|
return candidates.filter((filePath) => {
|
|
1031
1120
|
if (!exists(filePath)) return false;
|
|
@@ -1034,176 +1123,6 @@ function findLikelyEnvFiles(detection, env = process.env) {
|
|
|
1034
1123
|
});
|
|
1035
1124
|
}
|
|
1036
1125
|
|
|
1037
|
-
function stripQuotes(value) {
|
|
1038
|
-
const trimmed = String(value || '').trim();
|
|
1039
|
-
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
1040
|
-
return trimmed.slice(1, -1);
|
|
1041
|
-
}
|
|
1042
|
-
return trimmed;
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
function readEnvVar(filePath, name) {
|
|
1046
|
-
if (!exists(filePath)) return '';
|
|
1047
|
-
const raw = safeRead(filePath);
|
|
1048
|
-
const pattern = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*([^\\n#]+)`, 'm');
|
|
1049
|
-
const match = raw.match(pattern);
|
|
1050
|
-
return match ? stripQuotes(match[1]) : '';
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
function readFirstLineSecret(filePath) {
|
|
1054
|
-
if (!exists(filePath)) return '';
|
|
1055
|
-
return safeRead(filePath).split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
function readNpmrcToken(filePath) {
|
|
1059
|
-
if (!exists(filePath)) return '';
|
|
1060
|
-
const raw = safeRead(filePath);
|
|
1061
|
-
const match = raw.match(/\/\/registry\.npmjs\.org\/:_authToken\s*=\s*([^\s]+)/);
|
|
1062
|
-
return match ? stripQuotes(match[1]) : '';
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
function fingerprint(value) {
|
|
1066
|
-
const secret = String(value || '').trim();
|
|
1067
|
-
if (!secret) return null;
|
|
1068
|
-
return crypto.createHash('sha256').update(secret).digest('hex').slice(0, 12);
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
function npmTokenPaths(env = process.env) {
|
|
1072
|
-
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
1073
|
-
return {
|
|
1074
|
-
home,
|
|
1075
|
-
openclawEnv: path.join(home, '.openclaw', '.env'),
|
|
1076
|
-
credentialFile: path.join(home, '.openclaw', 'credentials', 'npm-getmarrow-token.txt'),
|
|
1077
|
-
npmrc: path.join(home, '.npmrc'),
|
|
1078
|
-
};
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
function inspectNpmTokenConfig(env = process.env) {
|
|
1082
|
-
const paths = npmTokenPaths(env);
|
|
1083
|
-
const openclawToken = readEnvVar(paths.openclawEnv, 'NPM_TOKEN');
|
|
1084
|
-
const credentialToken = readFirstLineSecret(paths.credentialFile);
|
|
1085
|
-
let npmrcToken = '';
|
|
1086
|
-
let unsafeNpmrcPath = false;
|
|
1087
|
-
try {
|
|
1088
|
-
assertDirectOwnerFile(paths.home, paths.npmrc, { allowMissing: true });
|
|
1089
|
-
npmrcToken = readNpmrcToken(paths.npmrc);
|
|
1090
|
-
} catch {
|
|
1091
|
-
unsafeNpmrcPath = true;
|
|
1092
|
-
}
|
|
1093
|
-
const sourceToken = openclawToken || credentialToken;
|
|
1094
|
-
const mismatch = Boolean(sourceToken && npmrcToken && fingerprint(sourceToken) !== fingerprint(npmrcToken));
|
|
1095
|
-
const missingNpmrcToken = Boolean(sourceToken && !npmrcToken);
|
|
1096
|
-
|
|
1097
|
-
return {
|
|
1098
|
-
safe: {
|
|
1099
|
-
npm_token: {
|
|
1100
|
-
checked: true,
|
|
1101
|
-
repairable: Boolean(sourceToken && (mismatch || missingNpmrcToken) && !unsafeNpmrcPath),
|
|
1102
|
-
mismatch,
|
|
1103
|
-
missing_npmrc_token: missingNpmrcToken,
|
|
1104
|
-
unsafe_path: unsafeNpmrcPath,
|
|
1105
|
-
sources: {
|
|
1106
|
-
openclaw_env: { path: paths.openclawEnv, present: Boolean(openclawToken), fingerprint: fingerprint(openclawToken) },
|
|
1107
|
-
credential_file: { path: paths.credentialFile, present: Boolean(credentialToken), fingerprint: fingerprint(credentialToken) },
|
|
1108
|
-
npmrc: { path: paths.npmrc, present: Boolean(npmrcToken), fingerprint: fingerprint(npmrcToken) },
|
|
1109
|
-
},
|
|
1110
|
-
recommended_fix: unsafeNpmrcPath
|
|
1111
|
-
? 'Refusing automatic npm token repair because ~/.npmrc or its home directory is not a direct, regular owner path.'
|
|
1112
|
-
: mismatch || missingNpmrcToken
|
|
1113
|
-
? 'Run npx @getmarrow/install --repair to sync ~/.npmrc from the active OpenClaw/getmarrow npm token source.'
|
|
1114
|
-
: null,
|
|
1115
|
-
},
|
|
1116
|
-
},
|
|
1117
|
-
raw: { paths, sourceToken, npmrcToken },
|
|
1118
|
-
};
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
function assertDirectOwnerFile(homePath, filePath, { allowMissing = false } = {}) {
|
|
1122
|
-
const home = path.resolve(homePath);
|
|
1123
|
-
const target = path.resolve(filePath);
|
|
1124
|
-
if (target !== path.join(home, '.npmrc')) throw new Error('npm token repair target must be the direct owner ~/.npmrc');
|
|
1125
|
-
if (!fs.existsSync(home)) throw new Error('npm token repair owner home does not exist');
|
|
1126
|
-
const homeStat = fs.lstatSync(home);
|
|
1127
|
-
if (!homeStat.isDirectory() || homeStat.isSymbolicLink() || fs.realpathSync(home) !== home) {
|
|
1128
|
-
throw new Error('npm token repair owner home must be a direct, non-symbolic directory');
|
|
1129
|
-
}
|
|
1130
|
-
if (!fs.existsSync(target)) {
|
|
1131
|
-
if (allowMissing) return;
|
|
1132
|
-
throw new Error('npm token repair target does not exist');
|
|
1133
|
-
}
|
|
1134
|
-
const targetStat = fs.lstatSync(target);
|
|
1135
|
-
if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
|
|
1136
|
-
throw new Error('npm token repair target must be a regular file, not a symbolic link');
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
function atomicWriteOwnerFile(homePath, filePath, contents) {
|
|
1141
|
-
const home = path.resolve(homePath);
|
|
1142
|
-
const target = path.resolve(filePath);
|
|
1143
|
-
const allowMissing = !fs.existsSync(target);
|
|
1144
|
-
assertDirectOwnerFile(home, target, { allowMissing });
|
|
1145
|
-
const tempPath = path.join(home, `.npmrc.marrow-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
1146
|
-
let descriptor;
|
|
1147
|
-
try {
|
|
1148
|
-
descriptor = fs.openSync(tempPath, 'wx', 0o600);
|
|
1149
|
-
fs.writeFileSync(descriptor, contents, { encoding: 'utf8' });
|
|
1150
|
-
fs.fsyncSync(descriptor);
|
|
1151
|
-
fs.closeSync(descriptor);
|
|
1152
|
-
descriptor = undefined;
|
|
1153
|
-
assertDirectOwnerFile(home, target, { allowMissing });
|
|
1154
|
-
fs.renameSync(tempPath, target);
|
|
1155
|
-
fs.chmodSync(target, 0o600);
|
|
1156
|
-
} finally {
|
|
1157
|
-
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
1158
|
-
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
function upsertNpmrcToken(homePath, filePath, token) {
|
|
1163
|
-
assertDirectOwnerFile(homePath, filePath, { allowMissing: true });
|
|
1164
|
-
const before = safeRead(filePath);
|
|
1165
|
-
const tokenLine = `//registry.npmjs.org/:_authToken=${token}`;
|
|
1166
|
-
let after;
|
|
1167
|
-
if (/\/\/registry\.npmjs\.org\/:_authToken\s*=/.test(before)) {
|
|
1168
|
-
after = before.replace(/\/\/registry\.npmjs\.org\/:_authToken\s*=\s*[^\n\r]+/, tokenLine);
|
|
1169
|
-
} else {
|
|
1170
|
-
const separator = before && !before.endsWith('\n') ? '\n' : '';
|
|
1171
|
-
after = `${before}${separator}${tokenLine}\n`;
|
|
1172
|
-
}
|
|
1173
|
-
if (before !== after) {
|
|
1174
|
-
if (before) {
|
|
1175
|
-
const backupPath = `${filePath}.marrow-backup`;
|
|
1176
|
-
if (fs.existsSync(backupPath) && fs.lstatSync(backupPath).isSymbolicLink()) {
|
|
1177
|
-
throw new Error('npm token repair backup must not be a symbolic link');
|
|
1178
|
-
}
|
|
1179
|
-
const backupTemp = path.join(path.resolve(homePath), `.npmrc.marrow-backup-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
1180
|
-
fs.writeFileSync(backupTemp, before, { mode: 0o600, flag: 'wx' });
|
|
1181
|
-
fs.renameSync(backupTemp, backupPath);
|
|
1182
|
-
fs.chmodSync(backupPath, 0o600);
|
|
1183
|
-
}
|
|
1184
|
-
atomicWriteOwnerFile(homePath, filePath, after);
|
|
1185
|
-
}
|
|
1186
|
-
return before !== after;
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
function repairConfigDiagnostics(diagnostics, env = process.env) {
|
|
1190
|
-
const inspection = inspectNpmTokenConfig(env);
|
|
1191
|
-
const npm = diagnostics.npm_token;
|
|
1192
|
-
const repairs = [];
|
|
1193
|
-
if (npm?.repairable && inspection.raw.sourceToken) {
|
|
1194
|
-
const changed = upsertNpmrcToken(inspection.raw.paths.home, inspection.raw.paths.npmrc, inspection.raw.sourceToken);
|
|
1195
|
-
repairs.push({
|
|
1196
|
-
type: 'npm_token_npmrc_sync',
|
|
1197
|
-
changed,
|
|
1198
|
-
path: inspection.raw.paths.npmrc,
|
|
1199
|
-
message: changed
|
|
1200
|
-
? 'Synced ~/.npmrc npm token from active OpenClaw/getmarrow token source.'
|
|
1201
|
-
: '~/.npmrc already matched the active OpenClaw/getmarrow token source.',
|
|
1202
|
-
});
|
|
1203
|
-
}
|
|
1204
|
-
return repairs;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
1126
|
function passiveInstructions() {
|
|
1208
1127
|
return `${MARROW_BLOCK_START}
|
|
1209
1128
|
## Marrow Passive Agent Memory
|
|
@@ -1244,7 +1163,7 @@ if (apiKey && !globalThis.__MARROW_PASSIVE_RUNTIME__) {
|
|
|
1244
1163
|
const { MarrowClient } = await import('@getmarrow/sdk');
|
|
1245
1164
|
const marrow = new MarrowClient(apiKey, {
|
|
1246
1165
|
baseUrl: installedBaseUrl,
|
|
1247
|
-
agentId: process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || installedAgentId,
|
|
1166
|
+
agentId: process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || installedAgentId || undefined,
|
|
1248
1167
|
sessionId: process.env.MARROW_SESSION_ID,
|
|
1249
1168
|
mode: process.env.MARROW_ENFORCEMENT_MODE || 'auto',
|
|
1250
1169
|
});
|
|
@@ -1268,12 +1187,15 @@ if (apiKey && !globalThis.__MARROW_PASSIVE_RUNTIME__) {
|
|
|
1268
1187
|
}
|
|
1269
1188
|
|
|
1270
1189
|
function envExample(options = {}) {
|
|
1271
|
-
const agentId = String(options.agentId || '
|
|
1190
|
+
const agentId = String(options.agentId || '').trim();
|
|
1272
1191
|
const client = String(options.client || 'custom').trim() || 'custom';
|
|
1273
1192
|
const baseUrl = String(options.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL;
|
|
1193
|
+
const agentLine = agentId
|
|
1194
|
+
? `MARROW_FLEET_AGENT_ID=${JSON.stringify(agentId)}`
|
|
1195
|
+
: '# MARROW_FLEET_AGENT_ID is optional. Unset, Marrow uses the key\'s bound agent or the plan seat.';
|
|
1274
1196
|
return `MARROW_API_KEY=mrw_live_replace_me
|
|
1275
1197
|
MARROW_BASE_URL=${JSON.stringify(baseUrl)}
|
|
1276
|
-
|
|
1198
|
+
${agentLine}
|
|
1277
1199
|
MARROW_CLIENT=${JSON.stringify(client)}
|
|
1278
1200
|
# MARROW_TOOL_PROFILE is intentionally unset: ordinary setup uses primary. Set core or full only as an explicit opt-in.
|
|
1279
1201
|
MARROW_ENFORCEMENT_MODE=auto
|
|
@@ -1371,14 +1293,24 @@ function exactHookDescriptors(settings, eventName, command, matcher) {
|
|
|
1371
1293
|
function marrowHookSubcommand(command) {
|
|
1372
1294
|
if (typeof command !== 'string') return null;
|
|
1373
1295
|
const match = command.trim().match(
|
|
1374
|
-
/^npx\s+(?:-y\s+)?(?:--package=@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp|@getmarrow\/mcp(?:@[^\s]+)?)\s+(?:(?:claude|codex|cursor|grok)-)?(context-hook|pre-action-hook|hook|session-hook)$/,
|
|
1296
|
+
/^npx\s+(?:-y\s+)?(?:--package=@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp|@getmarrow\/mcp(?:@[^\s]+)?)\s+(?:(?:claude|cline|codex|cursor|gemini|grok|windsurf)-)?(context-hook|pre-action-hook|hook|session-hook)$/,
|
|
1375
1297
|
);
|
|
1376
1298
|
return match?.[1] || null;
|
|
1377
1299
|
}
|
|
1378
1300
|
|
|
1301
|
+
// Which existing Marrow handler keeps its owner options (such as a timeout) when entries are
|
|
1302
|
+
// merged: the exact canonical entry, then a pinned entry with the same matcher (an earlier
|
|
1303
|
+
// version or entrypoint spelling of the canonical one), then the first Marrow handler.
|
|
1304
|
+
function marrowHandlerRank(hook, command, exactMatcher) {
|
|
1305
|
+
if (hook.command === command && exactMatcher) return 3;
|
|
1306
|
+
if (exactMatcher && /^npx\s+-y\s+--package=@getmarrow\/mcp@[^\s]+\s+marrow-mcp\s+/.test(String(hook.command || '').trim())) return 2;
|
|
1307
|
+
return 1;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1379
1310
|
function reconcileMarrowCommandHook(settings, eventName, subcommand, command, matcher, handlerOptions = {}) {
|
|
1380
1311
|
const original = Array.isArray(settings?.hooks?.[eventName]) ? settings.hooks[eventName] : [];
|
|
1381
1312
|
let preferredHandler = null;
|
|
1313
|
+
let preferredRank = 0;
|
|
1382
1314
|
const retained = [];
|
|
1383
1315
|
for (const entry of original) {
|
|
1384
1316
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !Array.isArray(entry.hooks)) {
|
|
@@ -1391,8 +1323,10 @@ function reconcileMarrowCommandHook(settings, eventName, subcommand, command, ma
|
|
|
1391
1323
|
&& hook.type === 'command' ? marrowHookSubcommand(hook.command) : null;
|
|
1392
1324
|
if (detected) {
|
|
1393
1325
|
const exactMatcher = matcher == null ? entry.matcher === undefined : entry.matcher === matcher;
|
|
1394
|
-
|
|
1326
|
+
const rank = detected === subcommand ? marrowHandlerRank(hook, command, exactMatcher) : 0;
|
|
1327
|
+
if (rank > preferredRank) {
|
|
1395
1328
|
preferredHandler = hook;
|
|
1329
|
+
preferredRank = rank;
|
|
1396
1330
|
}
|
|
1397
1331
|
continue;
|
|
1398
1332
|
}
|
|
@@ -2142,7 +2076,7 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
2142
2076
|
? `${sdkDependency.install_command} && npx @getmarrow/install --repair`
|
|
2143
2077
|
: sdkDependency.warning
|
|
2144
2078
|
: capabilityLevel === 'governed_wrapper'
|
|
2145
|
-
? `npx @getmarrow/install run --
|
|
2079
|
+
? `npx @getmarrow/install run -- ${client}`
|
|
2146
2080
|
: client === 'cline' && clineConflicts.length > 0
|
|
2147
2081
|
? 'Move or remove the conflicting owner-managed Cline hook file after owner review, then run npx @getmarrow/install --repair. Marrow will never overwrite or compose it.'
|
|
2148
2082
|
: client === 'gemini' && geminiHooksExplicitlyDisabled(geminiSettings)
|
|
@@ -2200,21 +2134,82 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
2200
2134
|
};
|
|
2201
2135
|
}
|
|
2202
2136
|
|
|
2137
|
+
const MCP_ENV_PLACEHOLDER_RE = /\$\{[^}]*\}/;
|
|
2138
|
+
const AGENT_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
2139
|
+
|
|
2140
|
+
// Values in reports are identity labels, never secrets: a URL is reduced to its origin, so
|
|
2141
|
+
// credentials embedded in it are never printed.
|
|
2142
|
+
function safeIdentityValue(field, value) {
|
|
2143
|
+
if (field === 'MARROW_BASE_URL') {
|
|
2144
|
+
try {
|
|
2145
|
+
const url = new URL(value);
|
|
2146
|
+
return `${url.protocol}//${url.host}`;
|
|
2147
|
+
} catch {
|
|
2148
|
+
return '[unparseable URL]';
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
return AGENT_ID_RE.test(value) ? value : '[invalid agent id]';
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function identityAllowlist(env = process.env) {
|
|
2155
|
+
const list = (value) => new Set(String(value || '').split(',').map((entry) => entry.trim()).filter(Boolean));
|
|
2156
|
+
return {
|
|
2157
|
+
baseUrls: list(env.MARROW_ALLOWED_BASE_URLS),
|
|
2158
|
+
agentIds: list(env.MARROW_ALLOWED_AGENT_IDS),
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
// The managed MCP entry always carries the configured identity: the base URL and agent id the
|
|
2163
|
+
// installer or controller runs with. An existing concrete value is kept only when it is equal
|
|
2164
|
+
// or on the owner's allowlist (MARROW_ALLOWED_BASE_URLS, MARROW_ALLOWED_AGENT_IDS); otherwise
|
|
2165
|
+
// it is replaced and the divergence is reported. A <client>-<hash> id written by an earlier
|
|
2166
|
+
// installer is removed without a report.
|
|
2203
2167
|
function upsertMcpServerConfig(filePath, options = {}) {
|
|
2204
2168
|
const agentId = String(options.agentId || '').trim();
|
|
2205
|
-
|
|
2206
|
-
const
|
|
2169
|
+
const derivedAgentIds = new Set(Array.isArray(options.derivedAgentIds) ? options.derivedAgentIds : []);
|
|
2170
|
+
const allowlist = options.allowlist || identityAllowlist();
|
|
2171
|
+
const report = typeof options.onDivergence === 'function' ? options.onDivergence : () => {};
|
|
2207
2172
|
const config = parseJsonObject(filePath);
|
|
2208
2173
|
const servers = config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
|
|
2209
2174
|
? config.mcpServers
|
|
2210
2175
|
: {};
|
|
2176
|
+
const existingEnv = servers.marrow?.env && typeof servers.marrow.env === 'object' && !Array.isArray(servers.marrow.env)
|
|
2177
|
+
? servers.marrow.env
|
|
2178
|
+
: {};
|
|
2179
|
+
const concrete = (value) => {
|
|
2180
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
2181
|
+
return text && !MCP_ENV_PLACEHOLDER_RE.test(text) ? text : '';
|
|
2182
|
+
};
|
|
2183
|
+
const configuredBaseUrl = String(options.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL;
|
|
2184
|
+
const existingBaseUrl = concrete(existingEnv.MARROW_BASE_URL);
|
|
2185
|
+
let baseUrl = configuredBaseUrl;
|
|
2186
|
+
if (existingBaseUrl && existingBaseUrl !== configuredBaseUrl) {
|
|
2187
|
+
if (allowlist.baseUrls.has(existingBaseUrl)) baseUrl = existingBaseUrl;
|
|
2188
|
+
else {
|
|
2189
|
+
report({
|
|
2190
|
+
field: 'MARROW_BASE_URL',
|
|
2191
|
+
replaced: safeIdentityValue('MARROW_BASE_URL', existingBaseUrl),
|
|
2192
|
+
applied: safeIdentityValue('MARROW_BASE_URL', configuredBaseUrl),
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
const existingAgentId = concrete(existingEnv.MARROW_FLEET_AGENT_ID);
|
|
2197
|
+
let keptAgentId = agentId;
|
|
2198
|
+
if (existingAgentId && existingAgentId !== agentId && !derivedAgentIds.has(existingAgentId)) {
|
|
2199
|
+
if (allowlist.agentIds.has(existingAgentId)) keptAgentId = existingAgentId;
|
|
2200
|
+
else {
|
|
2201
|
+
report({
|
|
2202
|
+
field: 'MARROW_FLEET_AGENT_ID',
|
|
2203
|
+
replaced: safeIdentityValue('MARROW_FLEET_AGENT_ID', existingAgentId),
|
|
2204
|
+
applied: agentId ? safeIdentityValue('MARROW_FLEET_AGENT_ID', agentId) : 'unset (resolved by Marrow)',
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2211
2208
|
const existingProfile = resolveToolProfile(servers.marrow?.env?.MARROW_TOOL_PROFILE).configured_profile;
|
|
2212
2209
|
const requestedProfile = resolveToolProfile(options.toolProfile).configured_profile;
|
|
2213
2210
|
const configuredProfile = requestedProfile === 'unset' ? existingProfile : requestedProfile;
|
|
2214
|
-
const env = {
|
|
2215
|
-
|
|
2216
|
-
MARROW_FLEET_AGENT_ID: agentId,
|
|
2217
|
-
};
|
|
2211
|
+
const env = { MARROW_BASE_URL: baseUrl };
|
|
2212
|
+
if (keptAgentId) env.MARROW_FLEET_AGENT_ID = keptAgentId;
|
|
2218
2213
|
if (configuredProfile !== 'unset') env.MARROW_TOOL_PROFILE = configuredProfile;
|
|
2219
2214
|
servers.marrow = {
|
|
2220
2215
|
command: 'npx',
|
|
@@ -2452,10 +2447,46 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
|
|
|
2452
2447
|
});
|
|
2453
2448
|
}
|
|
2454
2449
|
|
|
2450
|
+
const HERMES_WRITE_LABEL = 'Hermes MCP server (mcp_servers.marrow)';
|
|
2451
|
+
|
|
2452
|
+
function hermesTargetIsSafe(detection) {
|
|
2453
|
+
try {
|
|
2454
|
+
assertContainedManagedTarget(detection.paths.hermesHome, detection.paths.hermesConfig);
|
|
2455
|
+
return true;
|
|
2456
|
+
} catch {
|
|
2457
|
+
return false;
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
function derivedAgentIdsFor(root) {
|
|
2462
|
+
return [...SOURCE_CLIENTS].map((candidate) => stableAgentId(root, candidate));
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2455
2465
|
function buildPlan(detection, options) {
|
|
2456
2466
|
const client = options.client || detectedClient(detection);
|
|
2457
|
-
|
|
2467
|
+
// Server-facing configuration carries only a configured agent id. Without one, the MCP
|
|
2468
|
+
// server, SDK and hooks let Marrow resolve the key's bound agent or the plan seat.
|
|
2469
|
+
const agentId = String(options.agentId || '').trim();
|
|
2470
|
+
const derivedAgentIds = derivedAgentIdsFor(detection.root);
|
|
2458
2471
|
const baseUrl = String(options.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL;
|
|
2472
|
+
const mcpConfigOptions = {
|
|
2473
|
+
agentId,
|
|
2474
|
+
baseUrl,
|
|
2475
|
+
toolProfile: options.toolProfile,
|
|
2476
|
+
derivedAgentIds,
|
|
2477
|
+
allowlist: options.identityAllowlist || identityAllowlist(),
|
|
2478
|
+
};
|
|
2479
|
+
const mcpConfigWrite = (filePath, label) => {
|
|
2480
|
+
const write = { type: 'json-transform', path: filePath, label, identityDivergence: [] };
|
|
2481
|
+
write.transform = (target) => {
|
|
2482
|
+
write.identityDivergence = [];
|
|
2483
|
+
return retarget(upsertMcpServerConfig(target, {
|
|
2484
|
+
...mcpConfigOptions,
|
|
2485
|
+
onDivergence: (divergence) => write.identityDivergence.push(divergence),
|
|
2486
|
+
}));
|
|
2487
|
+
};
|
|
2488
|
+
return write;
|
|
2489
|
+
};
|
|
2459
2490
|
const mcpTargetVersion = executableMcpTarget(options).version;
|
|
2460
2491
|
const retarget = (value) => retargetMcpPackageSpec(value, mcpTargetVersion);
|
|
2461
2492
|
const mode = options.mode === 'auto'
|
|
@@ -2535,12 +2566,22 @@ function buildPlan(detection, options) {
|
|
|
2535
2566
|
transform: (filePath) => retarget(upsertGrokHooks(filePath)),
|
|
2536
2567
|
});
|
|
2537
2568
|
}
|
|
2538
|
-
writes.push(
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2569
|
+
writes.push(mcpConfigWrite(detection.paths.mcpJson, 'Project MCP server config'));
|
|
2570
|
+
// The owner's Hermes config changes only on an explicit install or update, never from the
|
|
2571
|
+
// controller's background maintenance pass.
|
|
2572
|
+
if (detection.hermesConfig && options.maintenance !== true && hermesTargetIsSafe(detection)) {
|
|
2573
|
+
writes.push({
|
|
2574
|
+
type: 'yaml-transform',
|
|
2575
|
+
path: detection.paths.hermesConfig,
|
|
2576
|
+
root: detection.paths.hermesHome,
|
|
2577
|
+
label: HERMES_WRITE_LABEL,
|
|
2578
|
+
transform: (before) => planHermesMcpConfig(before, {
|
|
2579
|
+
mcpPackageSpec: `@getmarrow/mcp@${mcpTargetVersion}`,
|
|
2580
|
+
keyReference: hermesEnvHasKey(detection.paths.hermesEnv),
|
|
2581
|
+
}),
|
|
2582
|
+
conflict_fix: (reason) => `Marrow left ${detection.paths.hermesConfig} unchanged because it could not be edited safely (${reason}). Add this block under mcp_servers by hand, then restart Hermes:\n${hermesManualBlock({ mcpPackageSpec: `@getmarrow/mcp@${mcpTargetVersion}`, keyReference: hermesEnvHasKey(detection.paths.hermesEnv) })}`,
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2544
2585
|
if (detection.cursor) {
|
|
2545
2586
|
writes.push({
|
|
2546
2587
|
type: 'json-transform',
|
|
@@ -2548,12 +2589,7 @@ function buildPlan(detection, options) {
|
|
|
2548
2589
|
label: 'Cursor native hooks',
|
|
2549
2590
|
transform: (filePath) => retarget(upsertCursorHooks(filePath)),
|
|
2550
2591
|
});
|
|
2551
|
-
writes.push(
|
|
2552
|
-
type: 'json-transform',
|
|
2553
|
-
path: detection.paths.cursorMcp,
|
|
2554
|
-
label: 'Cursor MCP server config',
|
|
2555
|
-
transform: (filePath) => retarget(upsertMcpServerConfig(filePath, { agentId, baseUrl, toolProfile: options.toolProfile })),
|
|
2556
|
-
});
|
|
2592
|
+
writes.push(mcpConfigWrite(detection.paths.cursorMcp, 'Cursor MCP server config'));
|
|
2557
2593
|
}
|
|
2558
2594
|
}
|
|
2559
2595
|
|
|
@@ -2656,6 +2692,9 @@ function applyPlan(plan, options) {
|
|
|
2656
2692
|
const automaticRepairSuppressed = aheadUnverifiedVersions.length > 0;
|
|
2657
2693
|
let after;
|
|
2658
2694
|
let hookConflict = false;
|
|
2695
|
+
let conflictReason = null;
|
|
2696
|
+
let hostedEntry = false;
|
|
2697
|
+
let undo = null;
|
|
2659
2698
|
if (automaticRepairSuppressed) {
|
|
2660
2699
|
after = before;
|
|
2661
2700
|
} else if (write.type === 'file') {
|
|
@@ -2677,6 +2716,17 @@ function applyPlan(plan, options) {
|
|
|
2677
2716
|
after = write.transform(write.path);
|
|
2678
2717
|
if (equivalentManagedJson(before, after)) after = before;
|
|
2679
2718
|
}
|
|
2719
|
+
} else if (write.type === 'yaml-transform') {
|
|
2720
|
+
const planned = write.transform(before);
|
|
2721
|
+
if (planned.action === 'refuse') {
|
|
2722
|
+
after = before;
|
|
2723
|
+
hookConflict = true;
|
|
2724
|
+
conflictReason = planned.reason;
|
|
2725
|
+
} else {
|
|
2726
|
+
after = planned.action === 'update' ? planned.content : before;
|
|
2727
|
+
hostedEntry = planned.hosted === true;
|
|
2728
|
+
undo = planned.action === 'update' ? planned.undo || [] : null;
|
|
2729
|
+
}
|
|
2680
2730
|
} else if (write.type === 'owned-executable') {
|
|
2681
2731
|
if (fileExists && before !== write.content) {
|
|
2682
2732
|
after = before;
|
|
@@ -2695,6 +2745,9 @@ function applyPlan(plan, options) {
|
|
|
2695
2745
|
before,
|
|
2696
2746
|
after,
|
|
2697
2747
|
hookConflict,
|
|
2748
|
+
conflictReason,
|
|
2749
|
+
hostedEntry,
|
|
2750
|
+
undo,
|
|
2698
2751
|
modeChanged,
|
|
2699
2752
|
automaticRepairSuppressed,
|
|
2700
2753
|
aheadUnverifiedVersions,
|
|
@@ -2707,6 +2760,9 @@ function applyPlan(plan, options) {
|
|
|
2707
2760
|
before,
|
|
2708
2761
|
after,
|
|
2709
2762
|
hookConflict,
|
|
2763
|
+
conflictReason,
|
|
2764
|
+
hostedEntry,
|
|
2765
|
+
undo,
|
|
2710
2766
|
modeChanged,
|
|
2711
2767
|
automaticRepairSuppressed,
|
|
2712
2768
|
aheadUnverifiedVersions,
|
|
@@ -2727,7 +2783,14 @@ function applyPlan(plan, options) {
|
|
|
2727
2783
|
ahead_unverified_versions: aheadUnverifiedVersions,
|
|
2728
2784
|
exact_fix: mcpRegistryVerificationAction(aheadUnverifiedVersions),
|
|
2729
2785
|
} : {}),
|
|
2730
|
-
...(hookConflict ? {
|
|
2786
|
+
...(hookConflict ? {
|
|
2787
|
+
exact_fix: typeof write.conflict_fix === 'function' ? write.conflict_fix(conflictReason) : write.conflict_fix,
|
|
2788
|
+
...(conflictReason ? { conflict_reason: conflictReason } : {}),
|
|
2789
|
+
} : {}),
|
|
2790
|
+
...(hostedEntry ? { hosted_entry_preserved: true } : {}),
|
|
2791
|
+
// Redacted marrow-entry lines only; the file itself is never copied.
|
|
2792
|
+
...(undo && contentChanged ? { undo } : {}),
|
|
2793
|
+
...(write.identityDivergence?.length && changed ? { identity_divergence: write.identityDivergence } : {}),
|
|
2731
2794
|
});
|
|
2732
2795
|
if (contentChanged && writeApplied) {
|
|
2733
2796
|
atomicWriteManagedFile(path.resolve(write.root || root), write.path, after);
|
|
@@ -2740,8 +2803,10 @@ function applyPlan(plan, options) {
|
|
|
2740
2803
|
return changes;
|
|
2741
2804
|
}
|
|
2742
2805
|
|
|
2806
|
+
const SELF_TEST_READ_TIMEOUT_MS = 15_000;
|
|
2807
|
+
|
|
2743
2808
|
async function requestJson(url, options) {
|
|
2744
|
-
const res = await fetch(url, options);
|
|
2809
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(SELF_TEST_READ_TIMEOUT_MS), ...options });
|
|
2745
2810
|
const text = await res.text();
|
|
2746
2811
|
let json = {};
|
|
2747
2812
|
try {
|
|
@@ -2760,6 +2825,8 @@ const SELF_TEST_WRITE_ATTEMPTS = 3;
|
|
|
2760
2825
|
const SELF_TEST_RETRY_DELAY_MS = 1_000;
|
|
2761
2826
|
const SELF_TEST_MAX_RETRY_DELAY_MS = 2_000;
|
|
2762
2827
|
const SELF_TEST_TRANSIENT_STATUSES = new Set([429, 502, 503, 504]);
|
|
2828
|
+
const SELF_TEST_ATTEMPT_TIMEOUT_MS = 10_000;
|
|
2829
|
+
const SELF_TEST_TOTAL_TIMEOUT_MS = 25_000;
|
|
2763
2830
|
|
|
2764
2831
|
function selfTestRetryDelayMs(response, data, override) {
|
|
2765
2832
|
if (Number.isFinite(override) && override >= 0) return override;
|
|
@@ -2774,11 +2841,28 @@ function selfTestRetryDelayMs(response, data, override) {
|
|
|
2774
2841
|
// so a retry can never create a second record. As in the MCP client, a pending
|
|
2775
2842
|
// acknowledgement is never complete, even when it names a created decision: that id
|
|
2776
2843
|
// is pinned and the final response must carry the same one.
|
|
2777
|
-
async function selfTestWrite(url, options, { idempotencyKey, complete, retryDelayMs }) {
|
|
2844
|
+
async function selfTestWrite(url, options, { idempotencyKey, complete, retryDelayMs, attemptTimeoutMs, deadlineMs }) {
|
|
2778
2845
|
let lastState = 'no response';
|
|
2779
2846
|
let pinnedDecisionId = null;
|
|
2847
|
+
const perAttempt = Number.isFinite(attemptTimeoutMs) ? attemptTimeoutMs : SELF_TEST_ATTEMPT_TIMEOUT_MS;
|
|
2848
|
+
const deadline = Date.now() + (Number.isFinite(deadlineMs) ? deadlineMs : SELF_TEST_TOTAL_TIMEOUT_MS);
|
|
2780
2849
|
for (let attempt = 1; attempt <= SELF_TEST_WRITE_ATTEMPTS; attempt += 1) {
|
|
2781
|
-
const
|
|
2850
|
+
const remaining = deadline - Date.now();
|
|
2851
|
+
if (remaining <= 0) return { failed: `deadline reached (last: ${lastState})` };
|
|
2852
|
+
let res;
|
|
2853
|
+
try {
|
|
2854
|
+
// Each attempt is bounded, and all attempts share one deadline, so a stalled service
|
|
2855
|
+
// can never hold the install open.
|
|
2856
|
+
res = await fetch(url, {
|
|
2857
|
+
...options,
|
|
2858
|
+
headers: { ...options.headers, 'idempotency-key': idempotencyKey },
|
|
2859
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(perAttempt, remaining))),
|
|
2860
|
+
});
|
|
2861
|
+
} catch (error) {
|
|
2862
|
+
if (error?.name !== 'TimeoutError' && error?.name !== 'AbortError') throw error;
|
|
2863
|
+
lastState = 'timed out';
|
|
2864
|
+
continue;
|
|
2865
|
+
}
|
|
2782
2866
|
const text = await res.text();
|
|
2783
2867
|
let json = {};
|
|
2784
2868
|
try {
|
|
@@ -2808,7 +2892,8 @@ async function selfTestWrite(url, options, { idempotencyKey, complete, retryDela
|
|
|
2808
2892
|
throw new Error(String(json.error || json.message || `HTTP ${res.status}`));
|
|
2809
2893
|
}
|
|
2810
2894
|
if (attempt < SELF_TEST_WRITE_ATTEMPTS) {
|
|
2811
|
-
|
|
2895
|
+
const wait = Math.min(selfTestRetryDelayMs(res, data, retryDelayMs), Math.max(0, deadline - Date.now()));
|
|
2896
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
2812
2897
|
}
|
|
2813
2898
|
}
|
|
2814
2899
|
return { failed: lastState };
|
|
@@ -2891,6 +2976,61 @@ function runtimeGateVerified(runtime) {
|
|
|
2891
2976
|
return ['allow', 'warn', 'review_required', 'block'].includes(decision);
|
|
2892
2977
|
}
|
|
2893
2978
|
|
|
2979
|
+
const ACTIVATION_UNBOUND_KEY_FIX = 'This API key is not bound to one agent, so Marrow cannot confirm activation for a specific agent. Bind the key to one agent in the Marrow dashboard, or register the agent with POST /v1/agents and set MARROW_AGENT_ID to its id, then run npx -y @getmarrow/install@latest doctor --self-test.';
|
|
2980
|
+
|
|
2981
|
+
// The agent Marrow resolved for this key: its single bound agent or the Free plan seat. An
|
|
2982
|
+
// unbound key has no single agent, and a locally derived id is never substituted for it.
|
|
2983
|
+
function serverAgentIdentity(status, runtime) {
|
|
2984
|
+
const identity = status?.identity && typeof status.identity === 'object' ? status.identity : {};
|
|
2985
|
+
const safe = (value) => (typeof value === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? value : '');
|
|
2986
|
+
const bound = Array.isArray(identity.bound_agent_ids) ? identity.bound_agent_ids.map(safe).filter(Boolean) : [];
|
|
2987
|
+
const fromStatus = safe(identity.agent_id) || (bound.length === 1 ? bound[0] : '');
|
|
2988
|
+
if (fromStatus) return { agent_id: fromStatus, source: 'server_status_identity', bound_agent_ids: bound };
|
|
2989
|
+
const fromRuntime = safe(runtime?.agent_id);
|
|
2990
|
+
if (fromRuntime) return { agent_id: fromRuntime, source: 'server_runtime', bound_agent_ids: bound };
|
|
2991
|
+
return { agent_id: null, source: 'unresolved', bound_agent_ids: bound };
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
// A runtime call that creates a decision leaves it open unless it is closed. The self-test
|
|
2995
|
+
// closes its own runtime decision in the same session with the runtime authorization id as
|
|
2996
|
+
// gate_receipt_id. A failed close is reported, never retried as a new decision.
|
|
2997
|
+
async function closeSelfTestRuntimeDecision(baseUrl, headers, runtime, selfTestKey, options) {
|
|
2998
|
+
const authorization = runtime?.runtime_authorization && typeof runtime.runtime_authorization === 'object'
|
|
2999
|
+
? runtime.runtime_authorization
|
|
3000
|
+
: {};
|
|
3001
|
+
const decisionId = typeof runtime?.decision_id === 'string' && runtime.decision_id
|
|
3002
|
+
? runtime.decision_id
|
|
3003
|
+
: typeof authorization.decision_id === 'string' ? authorization.decision_id : '';
|
|
3004
|
+
if (!decisionId || authorization.decision_state === 'not_created') return { created: false, committed: null };
|
|
3005
|
+
const gateReceiptId = String(authorization.id || runtime?.gate_receipt?.id || runtime?.risk_gate?.gate_receipt_id || '');
|
|
3006
|
+
try {
|
|
3007
|
+
const closed = await selfTestWrite(`${baseUrl}/v1/agent/commit`, {
|
|
3008
|
+
method: 'POST',
|
|
3009
|
+
headers,
|
|
3010
|
+
body: JSON.stringify({
|
|
3011
|
+
decision_id: decisionId,
|
|
3012
|
+
success: true,
|
|
3013
|
+
outcome: 'Marrow installer self-test runtime check responded; no action was executed.',
|
|
3014
|
+
...(gateReceiptId ? { gate_receipt_id: gateReceiptId } : {}),
|
|
3015
|
+
proof: {
|
|
3016
|
+
checks: ['installer self-test runtime responded'],
|
|
3017
|
+
outcome: 'self-test runtime decision closed',
|
|
3018
|
+
},
|
|
3019
|
+
}),
|
|
3020
|
+
}, {
|
|
3021
|
+
idempotencyKey: `${selfTestKey}:runtime-commit`,
|
|
3022
|
+
complete: (data) => data,
|
|
3023
|
+
retryDelayMs: options.selfTestRetryDelayMs,
|
|
3024
|
+
attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
|
|
3025
|
+
deadlineMs: options.selfTestDeadlineMs,
|
|
3026
|
+
});
|
|
3027
|
+
if (closed?.failed) return { created: true, decision_id: decisionId, committed: false, error: closed.failed };
|
|
3028
|
+
return { created: true, decision_id: decisionId, committed: closed?.committed === true };
|
|
3029
|
+
} catch (error) {
|
|
3030
|
+
return { created: true, decision_id: decisionId, committed: false, error: error instanceof Error ? error.message : String(error) };
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
|
|
2894
3034
|
async function runSelfTest(options) {
|
|
2895
3035
|
const initialProfile = initialToolProfileReport(options.toolProfile);
|
|
2896
3036
|
if (!options.selfTest) return { skipped: true, reason: 'disabled', mcp_tool_profile: initialProfile };
|
|
@@ -2914,7 +3054,10 @@ async function runSelfTest(options) {
|
|
|
2914
3054
|
'x-marrow-sdk-version': SDK_ADAPTER_VERSION,
|
|
2915
3055
|
'x-marrow-mcp-version': executableMcpTarget(options).version,
|
|
2916
3056
|
};
|
|
2917
|
-
|
|
3057
|
+
// Only a configured id is sent. Without one, Marrow resolves the key's bound agent or the
|
|
3058
|
+
// plan seat, and the self-test reads that server answer back for activation.
|
|
3059
|
+
const configuredAgentId = String(options.agentId || '').trim();
|
|
3060
|
+
if (configuredAgentId) headers['x-marrow-agent-id'] = configuredAgentId;
|
|
2918
3061
|
|
|
2919
3062
|
const baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
2920
3063
|
const selfTestKey = `install-self-test:${crypto.randomUUID()}`;
|
|
@@ -2934,6 +3077,8 @@ async function runSelfTest(options) {
|
|
|
2934
3077
|
idempotencyKey: `${selfTestKey}:think`,
|
|
2935
3078
|
complete: (data) => selfTestDecisionId(data),
|
|
2936
3079
|
retryDelayMs: options.selfTestRetryDelayMs,
|
|
3080
|
+
attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
|
|
3081
|
+
deadlineMs: options.selfTestDeadlineMs,
|
|
2937
3082
|
});
|
|
2938
3083
|
|
|
2939
3084
|
const decisionId = typeof think === 'string' ? think : undefined;
|
|
@@ -2953,6 +3098,8 @@ async function runSelfTest(options) {
|
|
|
2953
3098
|
idempotencyKey: `${selfTestKey}:commit`,
|
|
2954
3099
|
complete: (data) => data,
|
|
2955
3100
|
retryDelayMs: options.selfTestRetryDelayMs,
|
|
3101
|
+
attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
|
|
3102
|
+
deadlineMs: options.selfTestDeadlineMs,
|
|
2956
3103
|
});
|
|
2957
3104
|
if (commit?.failed) {
|
|
2958
3105
|
throw new Error(`self-test commit did not complete after ${SELF_TEST_WRITE_ATTEMPTS} attempts (last: ${commit.failed})`);
|
|
@@ -2967,7 +3114,7 @@ async function runSelfTest(options) {
|
|
|
2967
3114
|
context?.primary_tool_availability,
|
|
2968
3115
|
Boolean(options.activation),
|
|
2969
3116
|
);
|
|
2970
|
-
const runtime = await
|
|
3117
|
+
const runtime = await selfTestWrite(`${baseUrl}/v1/agent/runtime`, {
|
|
2971
3118
|
method: 'POST',
|
|
2972
3119
|
headers,
|
|
2973
3120
|
body: JSON.stringify({
|
|
@@ -2981,13 +3128,26 @@ async function runSelfTest(options) {
|
|
|
2981
3128
|
outcome: 'self-test outcome committed',
|
|
2982
3129
|
},
|
|
2983
3130
|
}),
|
|
3131
|
+
}, {
|
|
3132
|
+
idempotencyKey: `${selfTestKey}:runtime`,
|
|
3133
|
+
complete: (data) => data,
|
|
3134
|
+
retryDelayMs: options.selfTestRetryDelayMs,
|
|
3135
|
+
attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
|
|
3136
|
+
deadlineMs: options.selfTestDeadlineMs,
|
|
2984
3137
|
});
|
|
3138
|
+
if (runtime?.failed) {
|
|
3139
|
+
throw new Error(`self-test runtime did not complete after ${SELF_TEST_WRITE_ATTEMPTS} attempts (last: ${runtime.failed})`);
|
|
3140
|
+
}
|
|
3141
|
+
const runtimeDecisionClosure = await closeSelfTestRuntimeDecision(baseUrl, headers, runtime, selfTestKey, options);
|
|
3142
|
+
const serverIdentity = serverAgentIdentity(status, runtime);
|
|
3143
|
+
const activationAgentId = configuredAgentId || serverIdentity.agent_id || '';
|
|
3144
|
+
const activationIdentityUnresolved = Boolean(options.activation && !activationAgentId);
|
|
2985
3145
|
const performance = await requestJson(`${baseUrl}/v1/analytics/agent-performance?period=7`, { headers })
|
|
2986
3146
|
.catch((error) => ({
|
|
2987
3147
|
ok: false,
|
|
2988
3148
|
error: error instanceof Error ? error.message : String(error),
|
|
2989
3149
|
}));
|
|
2990
|
-
const
|
|
3150
|
+
const firstValueResult = await selfTestWrite(`${baseUrl}/v1/agent/first-value`, {
|
|
2991
3151
|
method: 'POST',
|
|
2992
3152
|
headers,
|
|
2993
3153
|
body: JSON.stringify({
|
|
@@ -3000,14 +3160,25 @@ async function runSelfTest(options) {
|
|
|
3000
3160
|
outcome: 'first-value endpoint reached',
|
|
3001
3161
|
},
|
|
3002
3162
|
decision_id: decisionId,
|
|
3003
|
-
agent_id:
|
|
3004
|
-
activation: options.activation ? {
|
|
3163
|
+
agent_id: activationAgentId || undefined,
|
|
3164
|
+
activation: options.activation && activationAgentId ? {
|
|
3005
3165
|
...options.activation,
|
|
3166
|
+
agent_id: activationAgentId,
|
|
3006
3167
|
intervention_verified: runtimeGateVerified(runtime),
|
|
3007
3168
|
closure_verified: true,
|
|
3008
3169
|
} : undefined,
|
|
3009
3170
|
}),
|
|
3171
|
+
}, {
|
|
3172
|
+
idempotencyKey: `${selfTestKey}:first-value`,
|
|
3173
|
+
complete: (data) => data,
|
|
3174
|
+
retryDelayMs: options.selfTestRetryDelayMs,
|
|
3175
|
+
attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
|
|
3176
|
+
deadlineMs: options.selfTestDeadlineMs,
|
|
3010
3177
|
});
|
|
3178
|
+
if (firstValueResult?.failed) {
|
|
3179
|
+
throw new Error(`self-test first-value did not complete after ${SELF_TEST_WRITE_ATTEMPTS} attempts (last: ${firstValueResult.failed})`);
|
|
3180
|
+
}
|
|
3181
|
+
const firstValue = firstValueResult;
|
|
3011
3182
|
const valueProof = await requestJson(`${baseUrl}/v1/agent/value/proof?period_days=30`, { headers })
|
|
3012
3183
|
.catch((error) => ({
|
|
3013
3184
|
ok: false,
|
|
@@ -3019,7 +3190,7 @@ async function runSelfTest(options) {
|
|
|
3019
3190
|
let activationReceipt = null;
|
|
3020
3191
|
let activationVerified = false;
|
|
3021
3192
|
let activationProfileReceipt = null;
|
|
3022
|
-
if (options.activation) {
|
|
3193
|
+
if (options.activation && !activationIdentityUnresolved) {
|
|
3023
3194
|
const activationAdapterVersion = options.activation.adapter_version || INSTALLER_ADAPTER_VERSION;
|
|
3024
3195
|
const activationCapabilityLevel = options.activation.capability_level || 'event_contract';
|
|
3025
3196
|
const activationExpectedHooks = Array.isArray(options.activation.expected_hooks) ? options.activation.expected_hooks : [];
|
|
@@ -3037,7 +3208,7 @@ async function runSelfTest(options) {
|
|
|
3037
3208
|
&& typeof activationReceipt.id === 'string'
|
|
3038
3209
|
&& activationReceipt.id.length > 0
|
|
3039
3210
|
&& activationReceipt.decision_id === decisionId
|
|
3040
|
-
&& activationReceipt.agent_id ===
|
|
3211
|
+
&& activationReceipt.agent_id === activationAgentId
|
|
3041
3212
|
&& activationReceipt.outcome_success === true
|
|
3042
3213
|
&& isCanonicalTimestamp(activationReceipt.outcome_recorded_at)
|
|
3043
3214
|
&& activationReceipt.server_confirmed === true
|
|
@@ -3056,7 +3227,7 @@ async function runSelfTest(options) {
|
|
|
3056
3227
|
event_id: `activation-${activationConfigFingerprint.slice(0, 32)}`,
|
|
3057
3228
|
event_type: 'activation_profile_registered',
|
|
3058
3229
|
harness: options.activation.harness,
|
|
3059
|
-
agent_id:
|
|
3230
|
+
agent_id: activationAgentId,
|
|
3060
3231
|
session_id: headers['x-marrow-session-id'],
|
|
3061
3232
|
adapter_version: activationAdapterVersion,
|
|
3062
3233
|
capability_level: activationCapabilityLevel,
|
|
@@ -3103,6 +3274,8 @@ async function runSelfTest(options) {
|
|
|
3103
3274
|
skipped: false,
|
|
3104
3275
|
mcp_tool_profile: toolProfile,
|
|
3105
3276
|
decision_id: decisionId,
|
|
3277
|
+
// Only committed:true is trusted closure; anything else is reported as such.
|
|
3278
|
+
decision_committed: commit?.committed === true,
|
|
3106
3279
|
active: Boolean(status.enabled ?? status.ok),
|
|
3107
3280
|
health: status.health || null,
|
|
3108
3281
|
last_event_at: status.last_event_at || null,
|
|
@@ -3112,7 +3285,13 @@ async function runSelfTest(options) {
|
|
|
3112
3285
|
runtime_active: runtimeGateVerified(runtime),
|
|
3113
3286
|
runtime_exact_next_action: runtime.exact_next_action || null,
|
|
3114
3287
|
runtime_before_you_act: runtime.before_you_act || null,
|
|
3288
|
+
runtime_decision_closure: runtimeDecisionClosure,
|
|
3289
|
+
agent_id: activationAgentId || null,
|
|
3290
|
+
agent_id_source: configuredAgentId ? 'configured' : serverIdentity.source,
|
|
3291
|
+
bound_agent_ids: serverIdentity.bound_agent_ids,
|
|
3115
3292
|
activation_verified: activationVerified,
|
|
3293
|
+
activation_identity_unresolved: activationIdentityUnresolved,
|
|
3294
|
+
activation_exact_fix: activationIdentityUnresolved ? ACTIVATION_UNBOUND_KEY_FIX : null,
|
|
3116
3295
|
activation_scope: options.activation ? 'server_self_test_only' : null,
|
|
3117
3296
|
coverage_verified: false,
|
|
3118
3297
|
passive_live: false,
|
|
@@ -3266,112 +3445,118 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
|
|
|
3266
3445
|
};
|
|
3267
3446
|
}
|
|
3268
3447
|
|
|
3269
|
-
function printReport(report) {
|
|
3270
|
-
process.stdout.write(
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3448
|
+
function printReport(report, sink) {
|
|
3449
|
+
const out = typeof sink === 'function' ? sink : (text) => process.stdout.write(text);
|
|
3450
|
+
out(`Marrow passive installer\n`);
|
|
3451
|
+
out(`Root: ${report.root}\n`);
|
|
3452
|
+
out(`Mode: ${report.mode}\n`);
|
|
3453
|
+
out(`Write mode: ${report.writeMode}\n\n`);
|
|
3274
3454
|
|
|
3275
3455
|
if (report.activation?.requested) {
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3456
|
+
out('Activation:\n');
|
|
3457
|
+
out(`- agent: ${report.activation.agent_id || 'none resolved'}${report.activation.agent_id_source ? ` (${report.activation.agent_id_source})` : ''}\n`);
|
|
3458
|
+
if (report.activation.exact_fix) out(`- exact fix: ${report.activation.exact_fix}\n`);
|
|
3459
|
+
out(`- self-test server confirmed: ${report.activation.server_confirmed ? 'yes' : 'no'}\n`);
|
|
3460
|
+
out(`- scope: ${report.activation.activation_scope}\n`);
|
|
3461
|
+
out(`- coverage verified: ${report.activation.coverage_verified ? 'yes' : 'no'}\n`);
|
|
3462
|
+
out(`- passive live in this process: ${report.activation.passive_live ? 'yes' : 'no'}\n`);
|
|
3463
|
+
out(`- reload required: ${report.activation.reload_required ? 'yes' : 'no'}\n`);
|
|
3464
|
+
if (report.activation.next_action) out(`- next action: ${report.activation.next_action}\n`);
|
|
3465
|
+
out('\n');
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
out('Detected:\n');
|
|
3288
3469
|
for (const [key, value] of Object.entries(report.detected)) {
|
|
3289
|
-
|
|
3470
|
+
out(`- ${key}: ${value ? 'yes' : 'no'}\n`);
|
|
3290
3471
|
}
|
|
3291
3472
|
|
|
3292
|
-
|
|
3473
|
+
out('\nPlanned changes:\n');
|
|
3293
3474
|
for (const change of report.changes) {
|
|
3294
3475
|
const marker = change.automatic_repair_suppressed
|
|
3295
3476
|
? 'preserved unverified-ahead surface; repair suppressed'
|
|
3296
3477
|
: change.hook_conflict ? 'preserved unmanaged owner file; review required'
|
|
3297
3478
|
: change.applied ? 'wrote' : change.changed ? 'would write' : 'unchanged';
|
|
3298
|
-
|
|
3479
|
+
out(`- ${marker}: ${change.label} (${change.path})\n`);
|
|
3299
3480
|
if (change.automatic_repair_suppressed && change.exact_fix) {
|
|
3300
|
-
|
|
3481
|
+
out(` exact verification: ${change.exact_fix}\n`);
|
|
3482
|
+
}
|
|
3483
|
+
if (change.hook_conflict && change.exact_fix) out(` exact fix: ${change.exact_fix}\n`);
|
|
3484
|
+
for (const divergence of change.identity_divergence || []) {
|
|
3485
|
+
out(` identity reset: ${divergence.field} ${divergence.replaced} -> ${divergence.applied}. If the previous value was intended, add it to ${divergence.field === 'MARROW_BASE_URL' ? 'MARROW_ALLOWED_BASE_URLS' : 'MARROW_ALLOWED_AGENT_IDS'} and rerun.\n`);
|
|
3301
3486
|
}
|
|
3302
|
-
if (change.hook_conflict && change.exact_fix) process.stdout.write(` exact fix: ${change.exact_fix}\n`);
|
|
3303
3487
|
}
|
|
3304
3488
|
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3489
|
+
out('\nLocal session loop guard:\n');
|
|
3490
|
+
out(`- configured: ${report.loop_guard_configured ? 'yes' : 'no'}\n`);
|
|
3491
|
+
out(`- isolated self-test passed: ${report.loop_guard_self_tested ? 'yes' : 'no'}\n`);
|
|
3492
|
+
out(`- observed in a reloaded host: ${report.loop_guard_observed ? 'yes' : 'no'}\n`);
|
|
3309
3493
|
if (!report.loop_guard_observed) {
|
|
3310
|
-
|
|
3494
|
+
out('- next: restart the host and complete its hook trust/review step before relying on live enforcement\n');
|
|
3311
3495
|
}
|
|
3312
3496
|
|
|
3313
|
-
|
|
3497
|
+
out('\nSelf-test:\n');
|
|
3314
3498
|
const toolProfile = report.selfTest.mcp_tool_profile || report.toolProfile;
|
|
3315
3499
|
if (toolProfile) {
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3500
|
+
out(`- configured tool profile: ${toolProfile.configured_profile}\n`);
|
|
3501
|
+
out(`- effective tool profile: ${toolProfile.effective_profile}\n`);
|
|
3502
|
+
out(`- expected visible tools: ${toolProfile.expected_visible_count == null ? 'complete catalog (awaiting reloaded MCP count)' : toolProfile.expected_visible_count}\n`);
|
|
3503
|
+
out(`- actual visible tools: ${toolProfile.actual_visible_count == null ? 'unavailable until process reload' : toolProfile.actual_visible_count}\n`);
|
|
3504
|
+
out(`- visible tool names: ${toolProfile.visibility_live ? toolProfile.visible_tool_names.join(', ') : 'unavailable until process reload'}\n`);
|
|
3505
|
+
out(`- profile live: ${toolProfile.visibility_live ? 'yes' : 'no'}\n`);
|
|
3322
3506
|
const projection = toolProfile.backend_entitlement_projection;
|
|
3323
3507
|
const availability = projection?.primary_tool_availability;
|
|
3324
3508
|
if (projection?.evidence_state === 'available' && availability?.entitlement_evidence?.state === 'available') {
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3509
|
+
out(`- backend-projected entitled tools: ${availability.counts.entitled}\n`);
|
|
3510
|
+
out(`- backend-projected upgrade-required tools: ${availability.counts.upgrade_required}\n`);
|
|
3511
|
+
out(`- backend projection source: ${projection.source}; authorizes calls: no\n`);
|
|
3328
3512
|
} else {
|
|
3329
|
-
|
|
3513
|
+
out(`- backend-projected entitlements: unavailable (source: ${projection?.source || 'backend_projection_not_provided'}; non-authorizing)\n`);
|
|
3330
3514
|
}
|
|
3331
3515
|
}
|
|
3332
3516
|
if (report.selfTest.skipped) {
|
|
3333
|
-
|
|
3334
|
-
if (report.selfTest.exact_fix)
|
|
3517
|
+
out(`- skipped: ${report.selfTest.reason}\n`);
|
|
3518
|
+
if (report.selfTest.exact_fix) out(`- exact fix: ${report.selfTest.exact_fix}\n`);
|
|
3335
3519
|
} else {
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
if (report.selfTest.
|
|
3520
|
+
out(`- active: ${report.selfTest.active ? 'yes' : 'no'}\n`);
|
|
3521
|
+
out(`- decision_id: ${report.selfTest.decision_id}\n`);
|
|
3522
|
+
out(`- decision committed (trusted closure): ${report.selfTest.decision_committed ? 'yes' : 'no'}\n`);
|
|
3523
|
+
out(`- health: ${report.selfTest.health || 'unknown'}\n`);
|
|
3524
|
+
out(`- one-call runtime: ${report.selfTest.runtime_active ? 'active' : 'not verified'}\n`);
|
|
3525
|
+
if (report.selfTest.error) out(`- error: ${report.selfTest.error}\n`);
|
|
3526
|
+
if (report.selfTest.next_action) out(`- next action: ${report.selfTest.next_action}\n`);
|
|
3342
3527
|
const update = report.selfTest.client_update;
|
|
3343
3528
|
const notification = update?.notification_state || update?.notification;
|
|
3344
3529
|
if (update && (update.update_available === true || update.version_status === 'unknown' || notification === 'unknown' || notification === 'version_unknown' || notification === 'security_required')) {
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
if (update.owner_notice)
|
|
3352
|
-
if (update.agent_instruction)
|
|
3530
|
+
out('\nMarrow client update:\n');
|
|
3531
|
+
out(`- priority: ${notification === 'security_required' ? 'security_required' : notification === 'recommended' ? 'recommended' : update.version_status === 'unknown' || notification === 'unknown' || notification === 'version_unknown' ? 'version_unknown' : update.priority || 'recommended'}\n`);
|
|
3532
|
+
out(`- installed: ${update.installed_version || update.current_version || 'unknown'}\n`);
|
|
3533
|
+
out(`- latest: ${update.latest_version || 'unknown'}\n`);
|
|
3534
|
+
out('- automatic notification: yes\n');
|
|
3535
|
+
out('- automatic local mutation: no; operator policy applies\n');
|
|
3536
|
+
if (update.owner_notice) out(`- tell owner: ${update.owner_notice}\n`);
|
|
3537
|
+
if (update.agent_instruction) out(`- agent instruction: ${update.agent_instruction}\n`);
|
|
3353
3538
|
if (update.update_command || update.exact_update_command || update.auto_update_command) {
|
|
3354
|
-
|
|
3539
|
+
out(`- update: ${update.auto_update_command || update.update_command || update.exact_update_command}\n`);
|
|
3355
3540
|
}
|
|
3356
|
-
if (update.verification_command || update.exact_verification_command)
|
|
3541
|
+
if (update.verification_command || update.exact_verification_command) out(`- verify: ${update.verification_command || update.exact_verification_command}\n`);
|
|
3357
3542
|
}
|
|
3358
3543
|
if (report.selfTest.first_value_signal) {
|
|
3359
|
-
|
|
3544
|
+
out('\nFirst value:\n');
|
|
3360
3545
|
const valueMoment = report.selfTest.install_value_moment;
|
|
3361
3546
|
if (valueMoment) {
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
for (const proof of valueMoment.proof)
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3547
|
+
out(`- ${valueMoment.headline}\n`);
|
|
3548
|
+
out('- First proof:\n');
|
|
3549
|
+
for (const proof of valueMoment.proof) out(` - ${proof}\n`);
|
|
3550
|
+
out(`- ${valueMoment.fleet_signal}\n`);
|
|
3551
|
+
out(`- Try this now: ${valueMoment.try_this_now}\n`);
|
|
3552
|
+
out(`- Expected: ${valueMoment.expected_response}\n`);
|
|
3368
3553
|
} else {
|
|
3369
|
-
|
|
3370
|
-
|
|
3554
|
+
out(`- ${report.selfTest.first_value_signal.headline}\n`);
|
|
3555
|
+
out(`- First useful lesson: ${report.selfTest.first_value_signal.first_lesson}\n`);
|
|
3371
3556
|
if (report.selfTest.first_value_signal.value_proof.length) {
|
|
3372
|
-
|
|
3557
|
+
out(`- Proof: ${report.selfTest.first_value_signal.value_proof.join('; ')}\n`);
|
|
3373
3558
|
}
|
|
3374
|
-
|
|
3559
|
+
out(`- Next: ${report.selfTest.first_value_signal.next_action}\n`);
|
|
3375
3560
|
}
|
|
3376
3561
|
}
|
|
3377
3562
|
if (report.selfTest.token_value_proof) {
|
|
@@ -3379,68 +3564,77 @@ function printReport(report) {
|
|
|
3379
3564
|
const observed = proof.observed || {};
|
|
3380
3565
|
const savings = proof.savings || {};
|
|
3381
3566
|
const tokens = observed.tokens || {};
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
if (savings.confidence)
|
|
3388
|
-
if (proof.proof_line)
|
|
3389
|
-
if (proof.exact_next_action)
|
|
3567
|
+
out('\nToken value proof:\n');
|
|
3568
|
+
out(`- passive capture: ${proof.enabled ? 'on' : 'unknown'}\n`);
|
|
3569
|
+
out(`- model calls observed: ${observed.model_calls || 0}\n`);
|
|
3570
|
+
out(`- tokens observed: ${tokens.total || 0}\n`);
|
|
3571
|
+
out(`- estimated tokens saved: ${savings.estimated_tokens_saved || 0}\n`);
|
|
3572
|
+
if (savings.confidence) out(`- confidence: ${savings.confidence}\n`);
|
|
3573
|
+
if (proof.proof_line) out(`- proof: ${proof.proof_line}\n`);
|
|
3574
|
+
if (proof.exact_next_action) out(`- next: ${proof.exact_next_action}\n`);
|
|
3390
3575
|
}
|
|
3391
3576
|
}
|
|
3392
3577
|
|
|
3393
3578
|
if (report.harnessReload && report.writeMode !== 'doctor') {
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
if (report.harnessReload.required && report.harnessReload.instruction)
|
|
3398
|
-
if (report.harnessReload.required && report.harnessReload.prove_command)
|
|
3579
|
+
out('\nHarness reload:\n');
|
|
3580
|
+
out(`- required: ${report.harnessReload.required ? 'yes' : 'no'}\n`);
|
|
3581
|
+
out(`- live in this process: ${report.harnessReload.live_in_this_process ? 'yes' : 'no'}\n`);
|
|
3582
|
+
if (report.harnessReload.required && report.harnessReload.instruction) out(`- restart: ${report.harnessReload.instruction}\n`);
|
|
3583
|
+
if (report.harnessReload.required && report.harnessReload.prove_command) out(`- prove after restart: ${report.harnessReload.prove_command}\n`);
|
|
3399
3584
|
}
|
|
3400
3585
|
if (report.firstCapture) {
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
if (report.firstCapture.command)
|
|
3405
|
-
|
|
3586
|
+
out('\nFirst capture:\n');
|
|
3587
|
+
out(`- client: ${report.firstCapture.client}\n`);
|
|
3588
|
+
out(`- capability: ${report.firstCapture.capability_level}\n`);
|
|
3589
|
+
if (report.firstCapture.command) out(`- command: ${report.firstCapture.command}\n`);
|
|
3590
|
+
out(`- ${report.firstCapture.instruction}\n`);
|
|
3406
3591
|
}
|
|
3407
3592
|
|
|
3408
3593
|
if (report.remediation) {
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
if (report.remediation.message)
|
|
3594
|
+
out('\nRemediation:\n');
|
|
3595
|
+
out(`- attempted: ${report.remediation.attempted ? 'yes' : 'no'}\n`);
|
|
3596
|
+
out(`- fixed config: ${report.remediation.fixedConfig ? 'yes' : 'no'}\n`);
|
|
3597
|
+
out(`- self-test passed: ${report.remediation.selfTestPassed ? 'yes' : 'no'}\n`);
|
|
3598
|
+
if (report.remediation.message) out(`- result: ${report.remediation.message}\n`);
|
|
3414
3599
|
}
|
|
3415
3600
|
|
|
3416
|
-
if (report.
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
process.stdout.write(`- repairable: ${npm.repairable ? 'yes' : 'no'}\n`);
|
|
3422
|
-
if (npm.recommended_fix) process.stdout.write(`- exact fix: ${npm.recommended_fix}\n`);
|
|
3601
|
+
if (report.sdkDependency?.required) {
|
|
3602
|
+
out('\nSDK dependency:\n');
|
|
3603
|
+
out(`- @getmarrow/sdk: ${report.sdkDependency.present ? 'present' : report.sdkDependency.ahead_unverified ? 'newer version requires verification' : 'missing'}\n`);
|
|
3604
|
+
if (report.sdkDependency.install_command) out(`- exact fix: ${report.sdkDependency.install_command}\n`);
|
|
3605
|
+
if (report.sdkDependency.warning) out(`- warning: ${report.sdkDependency.warning}\n`);
|
|
3423
3606
|
}
|
|
3424
3607
|
|
|
3425
|
-
if (report.
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3608
|
+
if (report.hermes?.detected) {
|
|
3609
|
+
out('\nHermes:\n');
|
|
3610
|
+
out(`- MCP server entry: ${report.hermes.state}${report.hermes.config_path ? ` (${report.hermes.config_path})` : ''}\n`);
|
|
3611
|
+
if (report.hermes.undo?.length) {
|
|
3612
|
+
out(`- undo (no backup is kept because the file holds other servers' credentials; values redacted): in ${report.hermes.config_path} under mcp_servers.marrow\n`);
|
|
3613
|
+
for (const step of report.hermes.undo) {
|
|
3614
|
+
if (step.change === 'replaced') {
|
|
3615
|
+
out(' replace these lines:\n');
|
|
3616
|
+
for (const line of step.lines) out(` ${line}\n`);
|
|
3617
|
+
out(' with the original lines:\n');
|
|
3618
|
+
for (const line of step.replaced) out(` ${line}\n`);
|
|
3619
|
+
} else {
|
|
3620
|
+
out(' remove these added lines:\n');
|
|
3621
|
+
for (const line of step.lines) out(` ${line}\n`);
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3429
3624
|
}
|
|
3625
|
+
if (report.hermes.key_source) out(`- API key source for the Hermes MCP server: ${report.hermes.key_source}\n`);
|
|
3626
|
+
if (report.hermes.owner_key_storage) out(`- ~/.marrow/env key storage: ${report.hermes.owner_key_storage.state}\n`);
|
|
3627
|
+
if (report.hermes.restart) out(`- restart: ${report.hermes.restart}\n`);
|
|
3628
|
+
if (report.hermes.exact_fix) out(`- exact fix: ${report.hermes.exact_fix}\n`);
|
|
3430
3629
|
}
|
|
3431
3630
|
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
}
|
|
3438
|
-
|
|
3439
|
-
process.stdout.write('\nAutomatic controller:\n');
|
|
3440
|
-
process.stdout.write(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
|
|
3441
|
-
if (report.controller?.started_at) process.stdout.write(`- started: ${report.controller.started_at}\n`);
|
|
3442
|
-
if (report.controller?.reason) process.stdout.write(`- reason: ${report.controller.reason}\n`);
|
|
3443
|
-
if (report.controller?.exact_fix) process.stdout.write(`- exact fix: ${report.controller.exact_fix}\n`);
|
|
3631
|
+
out('\nAutomatic controller:\n');
|
|
3632
|
+
out(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
|
|
3633
|
+
if (report.controller?.installer_version) out(`- installer version: ${report.controller.installer_version}\n`);
|
|
3634
|
+
if (report.controller?.restarted) out(`- restarted: ${report.controller.restarted.from_versions.join(', ')} -> ${report.controller.restarted.to_version}\n`);
|
|
3635
|
+
if (report.controller?.started_at) out(`- started: ${report.controller.started_at}\n`);
|
|
3636
|
+
if (report.controller?.reason) out(`- reason: ${report.controller.reason}\n`);
|
|
3637
|
+
if (report.controller?.exact_fix) out(`- exact fix: ${report.controller.exact_fix}\n`);
|
|
3444
3638
|
|
|
3445
3639
|
if (report.writeMode === 'doctor') {
|
|
3446
3640
|
const liveHere = !report.harnessReload?.required
|
|
@@ -3451,50 +3645,112 @@ function printReport(report) {
|
|
|
3451
3645
|
: report.doctor.active
|
|
3452
3646
|
? 'server confirmed, restart required'
|
|
3453
3647
|
: 'no';
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
if (report.doctor.envHints.length)
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3648
|
+
out('\nDoctor:\n');
|
|
3649
|
+
out(`- Marrow active: ${activeLabel}\n`);
|
|
3650
|
+
out(`- missing env: ${report.doctor.missingEnv.length ? report.doctor.missingEnv.join(', ') : 'none'}\n`);
|
|
3651
|
+
if (report.doctor.envHints.length) out(`- possible env files: ${report.doctor.envHints.join(', ')}\n`);
|
|
3652
|
+
out(`- missing hooks/config: ${report.doctor.missingHooks.length ? report.doctor.missingHooks.join('; ') : 'none'}\n`);
|
|
3653
|
+
out(`- loop guard configured: ${report.doctor.loop_guard_configured ? 'yes' : 'no'}\n`);
|
|
3654
|
+
out(`- loop guard isolated self-test: ${report.doctor.loop_guard_self_tested ? 'passed' : 'not passed'}\n`);
|
|
3655
|
+
out(`- loop guard observed after reload/trust: ${report.doctor.loop_guard_observed ? 'yes' : 'no'}\n`);
|
|
3462
3656
|
if (report.doctor.mcpProcesses?.available) {
|
|
3463
3657
|
const processes = report.doctor.mcpProcesses;
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
if (processes.ahead_unverified_versions.length)
|
|
3467
|
-
if (processes.automatic_repair_suppressed)
|
|
3658
|
+
out(`- MCP process versions: ${processes.active_versions.length ? processes.active_versions.join(', ') : processes.active_processes ? 'unknown' : 'none'}\n`);
|
|
3659
|
+
out(`- stale/mixed/version-unknown MCP clients: ${processes.healthy ? 'no' : 'yes'}\n`);
|
|
3660
|
+
if (processes.ahead_unverified_versions.length) out(`- unverified-ahead MCP clients: ${processes.ahead_unverified_versions.join(', ')}\n`);
|
|
3661
|
+
if (processes.automatic_repair_suppressed) out('- automatic MCP process repair: suppressed pending official registry verification\n');
|
|
3468
3662
|
}
|
|
3469
3663
|
if (report.doctor.mcpConfigurations) {
|
|
3470
3664
|
const configurations = report.doctor.mcpConfigurations;
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
if (configurations.ahead_unverified_versions.length)
|
|
3474
|
-
if (configurations.automatic_repair_suppressed)
|
|
3665
|
+
out(`- configured MCP versions: ${configurations.configured_versions.length ? configurations.configured_versions.join(', ') : 'none pinned'}\n`);
|
|
3666
|
+
out(`- stale/mixed/version-unknown MCP configuration: ${configurations.healthy ? 'no' : 'yes'}\n`);
|
|
3667
|
+
if (configurations.ahead_unverified_versions.length) out(`- unverified-ahead MCP configuration: ${configurations.ahead_unverified_versions.join(', ')}\n`);
|
|
3668
|
+
if (configurations.automatic_repair_suppressed) out('- automatic MCP configuration repair: suppressed pending official registry verification\n');
|
|
3475
3669
|
}
|
|
3476
|
-
if (report.doctor.recommendedFix)
|
|
3670
|
+
if (report.doctor.recommendedFix) out(`- recommended fix: ${report.doctor.recommendedFix}\n`);
|
|
3477
3671
|
if (report.doctor.mcp_update_required) {
|
|
3478
|
-
|
|
3479
|
-
|
|
3672
|
+
out(`- restart once after update: ${report.doctor.restart_instruction}\n`);
|
|
3673
|
+
out(`- verify after restart: ${report.doctor.verification_command}\n`);
|
|
3480
3674
|
}
|
|
3481
|
-
|
|
3675
|
+
out(`- live health: ${report.doctor.healthCommand}\n`);
|
|
3482
3676
|
}
|
|
3483
3677
|
|
|
3484
3678
|
if (report.writeMode === 'dry-run') {
|
|
3485
|
-
|
|
3679
|
+
out('\nRun with --yes to write these changes.\n');
|
|
3486
3680
|
}
|
|
3487
3681
|
|
|
3488
3682
|
if (report.warnings.length > 0) {
|
|
3489
|
-
|
|
3683
|
+
out('\nWarnings:\n');
|
|
3490
3684
|
for (const warning of report.warnings) {
|
|
3491
|
-
|
|
3685
|
+
out(`- ${warning}\n`);
|
|
3492
3686
|
}
|
|
3493
3687
|
}
|
|
3494
3688
|
}
|
|
3495
3689
|
|
|
3690
|
+
// The local controller directory is keyed by project and this id. It never leaves the machine.
|
|
3691
|
+
// Hermes passes only PATH, HOME and locale variables to an MCP server, plus its env block.
|
|
3692
|
+
// When neither the entry nor $HERMES_HOME/.env carries the key, the Marrow MCP server reads
|
|
3693
|
+
// it from the owner-only ~/.marrow/env, which is written here (mode 600) only if absent.
|
|
3694
|
+
function hermesWiringReport(detection, changes, options, planMode) {
|
|
3695
|
+
const paths = detection.paths || {};
|
|
3696
|
+
if (!detection.hermes) return { detected: false, state: 'not_detected' };
|
|
3697
|
+
if (planMode && !['mcp', 'both'].includes(planMode)) return { detected: true, state: 'skipped_for_mode', config_path: paths.hermesConfig };
|
|
3698
|
+
const mcpTargetVersion = options.mcpTargetVersion || MCP_ADAPTER_VERSION;
|
|
3699
|
+
const blockOptions = { mcpPackageSpec: `@getmarrow/mcp@${mcpTargetVersion}`, keyReference: hermesEnvHasKey(paths.hermesEnv) };
|
|
3700
|
+
if (!detection.hermesConfig) {
|
|
3701
|
+
return {
|
|
3702
|
+
detected: true,
|
|
3703
|
+
state: 'config_not_found',
|
|
3704
|
+
config_path: paths.hermesConfig,
|
|
3705
|
+
exact_fix: `Run Hermes once so it creates ${paths.hermesConfig}, then rerun this command, or add this block to it:\n${hermesManualBlock(blockOptions)}`,
|
|
3706
|
+
};
|
|
3707
|
+
}
|
|
3708
|
+
const change = (changes || []).find((entry) => entry.label === HERMES_WRITE_LABEL);
|
|
3709
|
+
if (!change) {
|
|
3710
|
+
return {
|
|
3711
|
+
detected: true,
|
|
3712
|
+
state: 'refused',
|
|
3713
|
+
config_path: paths.hermesConfig,
|
|
3714
|
+
exact_fix: `Marrow left ${paths.hermesConfig} unchanged because its path is not a direct, regular owner file. Add this block under mcp_servers by hand, then restart Hermes:\n${hermesManualBlock(blockOptions)}`,
|
|
3715
|
+
};
|
|
3716
|
+
}
|
|
3717
|
+
if (change.hook_conflict) {
|
|
3718
|
+
return { detected: true, state: 'refused', config_path: change.path, reason: change.conflict_reason || null, exact_fix: change.exact_fix };
|
|
3719
|
+
}
|
|
3720
|
+
if (change.automatic_repair_suppressed) {
|
|
3721
|
+
return { detected: true, state: 'preserved_unverified_ahead', config_path: change.path, exact_fix: change.exact_fix };
|
|
3722
|
+
}
|
|
3723
|
+
if (change.hosted_entry_preserved) {
|
|
3724
|
+
return { detected: true, state: 'hosted_entry_preserved', config_path: change.path };
|
|
3725
|
+
}
|
|
3726
|
+
const state = change.applied ? 'configured' : change.changed ? 'would_configure' : 'already_configured';
|
|
3727
|
+
let keySource = 'owner_env_file';
|
|
3728
|
+
let keyStorage = null;
|
|
3729
|
+
const current = safeRead(change.path);
|
|
3730
|
+
const entryKeys = hermesMarrowEnvKeyNames(current);
|
|
3731
|
+
if (entryKeys.includes('MARROW_API_KEY') || entryKeys.includes('MARROW_KEY')) {
|
|
3732
|
+
keySource = /MARROW_API_KEY:\s*["']?\$\{MARROW_API_KEY\}/.test(current) ? 'hermes_env_reference' : 'hermes_entry';
|
|
3733
|
+
} else if (state !== 'would_configure' && options.yes && !options.dryRun && !options.doctor) {
|
|
3734
|
+
keyStorage = ensureOwnerApiKey(options.home || detection.home, options.apiKey);
|
|
3735
|
+
}
|
|
3736
|
+
return {
|
|
3737
|
+
detected: true,
|
|
3738
|
+
state,
|
|
3739
|
+
config_path: change.path,
|
|
3740
|
+
undo: state === 'configured' ? change.undo || [] : null,
|
|
3741
|
+
key_source: keySource,
|
|
3742
|
+
...(keyStorage ? { owner_key_storage: { state: keyStorage.state, path: keyStorage.path } } : {}),
|
|
3743
|
+
restart: state === 'configured' ? 'Restart Hermes so it loads the Marrow MCP server.' : null,
|
|
3744
|
+
};
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
function localControllerAgentId(root, client, configuredAgentId = '') {
|
|
3748
|
+
return String(configuredAgentId || '').trim() || stableAgentId(root, client);
|
|
3749
|
+
}
|
|
3750
|
+
|
|
3496
3751
|
async function install(options) {
|
|
3497
|
-
//
|
|
3752
|
+
// Only a configured agent id is sent to Marrow. The controller keeps a local identity for its
|
|
3753
|
+
// state directory, which is never sent.
|
|
3498
3754
|
const configuredAgentId = String(options.agentId || '').trim();
|
|
3499
3755
|
if (options.activate && (options.yes !== true || options.dryRun || options.doctor)) {
|
|
3500
3756
|
throw new Error('activate requires write mode (--yes) because hooks must be installed during this run');
|
|
@@ -3519,7 +3775,8 @@ async function install(options) {
|
|
|
3519
3775
|
if (options.repair || options.update) assertUpdateTargetsManagedRoot(detection);
|
|
3520
3776
|
const client = detectedClient(detection);
|
|
3521
3777
|
options.client = client;
|
|
3522
|
-
options.agentId =
|
|
3778
|
+
options.agentId = configuredAgentId;
|
|
3779
|
+
const controllerIdentityAgentId = localControllerAgentId(detection.root, client, configuredAgentId);
|
|
3523
3780
|
const observedMcpProcesses = inspectMcpProcesses({ commands: options.processCommands });
|
|
3524
3781
|
const observedMcpConfigurations = inspectMcpConfigurations(detection, { paths: options.mcpConfigPaths });
|
|
3525
3782
|
const registryMetadata = await readMcpRegistryMetadata(options);
|
|
@@ -3561,7 +3818,7 @@ async function install(options) {
|
|
|
3561
3818
|
};
|
|
3562
3819
|
options.activation = options.activate ? {
|
|
3563
3820
|
harness: client,
|
|
3564
|
-
agent_id:
|
|
3821
|
+
agent_id: configuredAgentId || null,
|
|
3565
3822
|
install_surface: plan.mode,
|
|
3566
3823
|
mode: options.governanceMode || 'passive',
|
|
3567
3824
|
hooks_installed: changes
|
|
@@ -3582,12 +3839,7 @@ async function install(options) {
|
|
|
3582
3839
|
intervention_verified: false,
|
|
3583
3840
|
closure_verified: false,
|
|
3584
3841
|
} : null;
|
|
3585
|
-
const configInspection = inspectNpmTokenConfig();
|
|
3586
3842
|
const sdkDependency = inspectSdkDependency(detection);
|
|
3587
|
-
const configDiagnostics = configInspection.safe;
|
|
3588
|
-
const configRepairs = options.repair && options.yes && !options.dryRun && !options.doctor
|
|
3589
|
-
? repairConfigDiagnostics(configDiagnostics)
|
|
3590
|
-
: [];
|
|
3591
3843
|
const envHints = options.apiKey ? [] : findLikelyEnvFiles(detection);
|
|
3592
3844
|
const mcpProcesses = inspectMcpProcesses({
|
|
3593
3845
|
commands: options.processCommands,
|
|
@@ -3637,9 +3889,12 @@ async function install(options) {
|
|
|
3637
3889
|
'',
|
|
3638
3890
|
registryVerificationAction,
|
|
3639
3891
|
);
|
|
3640
|
-
|
|
3892
|
+
// An unbound key has no single agent to activate; the self-test still verified the account,
|
|
3893
|
+
// and the report carries the exact fix instead of failing the one-command install.
|
|
3894
|
+
if (options.activate && !selfTest.activation_verified && !selfTest.activation_identity_unresolved) {
|
|
3641
3895
|
throw new Error('Marrow activation failed: server confirmation was not returned');
|
|
3642
3896
|
}
|
|
3897
|
+
const hermes = hermesWiringReport(detection, changes, options, plan.mode);
|
|
3643
3898
|
const plannedHarnessReload = harnessReloadPlan(detection, options.doctor ? [] : changes);
|
|
3644
3899
|
const updateNeedsRestart = Boolean(options.update
|
|
3645
3900
|
&& !automaticMcpRepairSuppressed
|
|
@@ -3684,12 +3939,12 @@ async function install(options) {
|
|
|
3684
3939
|
reload_required: true,
|
|
3685
3940
|
};
|
|
3686
3941
|
}
|
|
3687
|
-
const changedConfig = changes.some((change) => change.applied)
|
|
3942
|
+
const changedConfig = changes.some((change) => change.applied);
|
|
3688
3943
|
const selfTestPassed = Boolean(!selfTest.skipped && selfTest.active && !selfTest.error);
|
|
3689
3944
|
const controllerPlatform = options.controllerPlatform || process.platform;
|
|
3690
3945
|
let controller = await controllerStatus({
|
|
3691
3946
|
root: detection.root,
|
|
3692
|
-
|
|
3947
|
+
identityAgentId: controllerIdentityAgentId,
|
|
3693
3948
|
platform: controllerPlatform,
|
|
3694
3949
|
});
|
|
3695
3950
|
const shouldEnsureController = options.controller !== false
|
|
@@ -3702,11 +3957,11 @@ async function install(options) {
|
|
|
3702
3957
|
&& (options.yes || options.activate || options.repair);
|
|
3703
3958
|
if (shouldEnsureController) {
|
|
3704
3959
|
try {
|
|
3705
|
-
controller = await
|
|
3960
|
+
controller = await ensureCurrentGovernanceController({
|
|
3706
3961
|
apiKey: options.apiKey,
|
|
3707
3962
|
baseUrl: options.baseUrl,
|
|
3708
3963
|
agentId: configuredAgentId,
|
|
3709
|
-
identityAgentId:
|
|
3964
|
+
identityAgentId: controllerIdentityAgentId,
|
|
3710
3965
|
client,
|
|
3711
3966
|
root: detection.root,
|
|
3712
3967
|
mode: plan.mode,
|
|
@@ -3775,8 +4030,10 @@ async function install(options) {
|
|
|
3775
4030
|
},
|
|
3776
4031
|
activation: {
|
|
3777
4032
|
requested: options.activate,
|
|
3778
|
-
agent_id:
|
|
4033
|
+
agent_id: selfTest.agent_id || configuredAgentId || null,
|
|
4034
|
+
agent_id_source: configuredAgentId ? 'configured' : selfTest.agent_id_source || 'unresolved',
|
|
3779
4035
|
server_confirmed: Boolean(selfTest.activation_verified),
|
|
4036
|
+
...(selfTest.activation_identity_unresolved ? { exact_fix: selfTest.activation_exact_fix } : {}),
|
|
3780
4037
|
activation_scope: selfTest.activation_scope || null,
|
|
3781
4038
|
coverage_verified: false,
|
|
3782
4039
|
passive_live: false,
|
|
@@ -3790,7 +4047,8 @@ async function install(options) {
|
|
|
3790
4047
|
loop_guard_observed: false,
|
|
3791
4048
|
loop_guard: loopGuardSelfTest,
|
|
3792
4049
|
harnessReload,
|
|
3793
|
-
firstCapture: firstCapturePath(detection
|
|
4050
|
+
firstCapture: firstCapturePath(detection),
|
|
4051
|
+
hermes,
|
|
3794
4052
|
changes,
|
|
3795
4053
|
doctor: {
|
|
3796
4054
|
active: Boolean(!selfTest.skipped && selfTest.active),
|
|
@@ -3811,7 +4069,7 @@ async function install(options) {
|
|
|
3811
4069
|
loop_guard_observed: false,
|
|
3812
4070
|
recommendedFix: registryVerificationAction
|
|
3813
4071
|
|| (updateAwaitingRestart ? INSTALLER_RESTART_INSTRUCTION : null)
|
|
3814
|
-
|| mcpProcesses.exact_fix || mcpConfigurations.exact_fix ||
|
|
4072
|
+
|| mcpProcesses.exact_fix || mcpConfigurations.exact_fix || (!options.apiKey
|
|
3815
4073
|
? envHints.length
|
|
3816
4074
|
? `MARROW_API_KEY was found in a likely env file at ${envHints[0]}. Load that key from trusted secret storage, export only MARROW_API_KEY, then run npx @getmarrow/install --repair.`
|
|
3817
4075
|
: 'Set MARROW_API_KEY, then run npx @getmarrow/install --repair.'
|
|
@@ -3819,8 +4077,6 @@ async function install(options) {
|
|
|
3819
4077
|
healthCommand: 'npx -y --package=@getmarrow/mcp@latest marrow-mcp ping',
|
|
3820
4078
|
},
|
|
3821
4079
|
remediation,
|
|
3822
|
-
configDiagnostics,
|
|
3823
|
-
configRepairs,
|
|
3824
4080
|
sdkDependency,
|
|
3825
4081
|
controller,
|
|
3826
4082
|
local_control: localControl,
|
|
@@ -3843,18 +4099,150 @@ async function install(options) {
|
|
|
3843
4099
|
};
|
|
3844
4100
|
}
|
|
3845
4101
|
|
|
4102
|
+
function activationFailureFix(message) {
|
|
4103
|
+
if (/Agent-bound key cannot access another agent|MARROW_AGENT_SCOPE_MISMATCH/i.test(message)) {
|
|
4104
|
+
return `unset MARROW_AGENT_ID and MARROW_FLEET_AGENT_ID so Marrow uses the key's own agent, then rerun ${INSTALLER_UPDATE_COMMAND}`;
|
|
4105
|
+
}
|
|
4106
|
+
return INSTALLER_DOCTOR_COMMAND;
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
function installCommandFor(options) {
|
|
4110
|
+
return options.update ? INSTALLER_UPDATE_COMMAND : 'npx -y @getmarrow/install@latest';
|
|
4111
|
+
}
|
|
4112
|
+
|
|
4113
|
+
// One summary for the one-command paths (first install and update). The full report goes to a
|
|
4114
|
+
// private log file. Nothing here prints the API key.
|
|
4115
|
+
function installSummaryLines(report, options) {
|
|
4116
|
+
const selfTest = report.selfTest || {};
|
|
4117
|
+
const activation = report.activation || {};
|
|
4118
|
+
const lines = [];
|
|
4119
|
+
const untrusted = !selfTest.skipped && !selfTest.error && selfTest.active && selfTest.decision_committed !== true;
|
|
4120
|
+
const selfTestFailed = !selfTest.skipped && (Boolean(selfTest.error) || !selfTest.active || untrusted);
|
|
4121
|
+
if (selfTest.skipped) {
|
|
4122
|
+
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: configuration updated; self-test skipped (${selfTest.reason}).${selfTest.exact_fix ? ` Fix: ${selfTest.exact_fix}` : ''}`);
|
|
4123
|
+
} else if (untrusted) {
|
|
4124
|
+
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: self-test decision ${selfTest.decision_id} was recorded without trusted closure (committed was not true). Fix: ${INSTALLER_DOCTOR_COMMAND}`);
|
|
4125
|
+
} else if (selfTestFailed) {
|
|
4126
|
+
const fix = report.doctor?.recommendedFix || INSTALLER_DOCTOR_COMMAND;
|
|
4127
|
+
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: self-test failed: ${selfTest.error || 'Marrow reported this account as inactive'}. Fix: ${fix}`);
|
|
4128
|
+
} else {
|
|
4129
|
+
const agent = activation.agent_id
|
|
4130
|
+
? `; agent ${activation.agent_id} (${activation.agent_id_source === 'configured' ? 'configured' : 'resolved by Marrow'})`
|
|
4131
|
+
: '; this key is not bound to one agent';
|
|
4132
|
+
lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: healthy. Self-test decision ${selfTest.decision_id} committed${agent}.`);
|
|
4133
|
+
}
|
|
4134
|
+
if (activation.exact_fix) lines.push(`Activation: ${activation.exact_fix}`);
|
|
4135
|
+
const apiKey = report.api_key || {};
|
|
4136
|
+
if (apiKey.source === 'owner_env_file' && apiKey.path) lines.push(`API key: read from ${apiKey.path}.`);
|
|
4137
|
+
if (apiKey.owner_files_disagree) {
|
|
4138
|
+
lines.push('Warning: ~/.marrow/env.local and ~/.marrow/env hold different Marrow keys. env.local takes precedence for the installer, hooks and the MCP server; keep only the intended key.');
|
|
4139
|
+
}
|
|
4140
|
+
const controller = report.controller || {};
|
|
4141
|
+
if (controller.restarted) {
|
|
4142
|
+
lines.push(`Controller restarted: ${controller.restarted.from_versions.join(', ')} -> ${controller.restarted.to_version}.`);
|
|
4143
|
+
} else if (controller.active && controller.changed) {
|
|
4144
|
+
lines.push('Controller started.');
|
|
4145
|
+
} else if (report.local_control?.state === 'disabled') {
|
|
4146
|
+
lines.push('Local control is disabled by the owner, so the controller was left stopped.');
|
|
4147
|
+
} else if (options.controller !== false && !controller.active && controller.exact_fix && controller.required !== false) {
|
|
4148
|
+
lines.push(`Controller: ${controller.state}. Fix: ${controller.exact_fix}`);
|
|
4149
|
+
}
|
|
4150
|
+
const hermes = report.hermes || {};
|
|
4151
|
+
if (hermes.state === 'configured') {
|
|
4152
|
+
lines.push(`Hermes: added the Marrow MCP server to ${hermes.config_path}; the undo steps are in the full report. Restart Hermes to load it.`);
|
|
4153
|
+
} else if (hermes.state === 'already_configured') {
|
|
4154
|
+
lines.push('Hermes: the Marrow MCP server is already configured.');
|
|
4155
|
+
} else if (hermes.state === 'refused' || hermes.state === 'config_not_found' || hermes.state === 'preserved_unverified_ahead') {
|
|
4156
|
+
lines.push(`Hermes: ${hermes.config_path || 'config.yaml'} was not changed. The exact block to add is in the full report.`);
|
|
4157
|
+
}
|
|
4158
|
+
if (hermes.owner_key_storage?.state === 'written') {
|
|
4159
|
+
lines.push('Stored your API key in ~/.marrow/env (mode 600) so the Hermes MCP server can read it; Hermes passes no other environment to MCP servers.');
|
|
4160
|
+
} else if (hermes.owner_key_storage?.state === 'different_key_present') {
|
|
4161
|
+
lines.push('Note: ~/.marrow/env already holds a different Marrow key; the Hermes MCP server will use that one.');
|
|
4162
|
+
}
|
|
4163
|
+
const divergences = (report.changes || []).flatMap((change) => (change.identity_divergence || []).map((divergence) => ({ ...divergence, path: change.path })));
|
|
4164
|
+
if (divergences.length) {
|
|
4165
|
+
lines.push(`MCP config identity reset: ${divergences.map((divergence) => `${divergence.field} in ${divergence.path}`).join('; ')}. Details and the allowlist fix are in the full report.`);
|
|
4166
|
+
}
|
|
4167
|
+
const reload = report.harnessReload || {};
|
|
4168
|
+
if (reload.required && reload.instruction && hermes.state !== 'configured') lines.push(`Restart: ${reload.instruction}`);
|
|
4169
|
+
else if (reload.required && reload.clients?.some((entry) => entry.client !== 'hermes')) {
|
|
4170
|
+
lines.push(`Restart: ${reload.clients.filter((entry) => entry.client !== 'hermes').map((entry) => entry.restart).join(' ')}`);
|
|
4171
|
+
}
|
|
4172
|
+
return { lines, selfTestFailed };
|
|
4173
|
+
}
|
|
4174
|
+
|
|
4175
|
+
function writeInstallLog(home, text) {
|
|
4176
|
+
const directory = path.join(home, '.marrow', 'logs');
|
|
4177
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
4178
|
+
const stat = fs.lstatSync(directory);
|
|
4179
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe log directory');
|
|
4180
|
+
fs.chmodSync(directory, 0o700);
|
|
4181
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
4182
|
+
const filePath = path.join(directory, `install-${stamp}-${process.pid}.log`);
|
|
4183
|
+
fs.writeFileSync(filePath, text, { flag: 'wx', mode: 0o600 });
|
|
4184
|
+
return filePath;
|
|
4185
|
+
}
|
|
4186
|
+
|
|
3846
4187
|
async function runCli(argv) {
|
|
3847
4188
|
const options = parseArgs(argv);
|
|
3848
4189
|
if (options.help) {
|
|
3849
4190
|
process.stdout.write(usage());
|
|
3850
4191
|
return;
|
|
3851
4192
|
}
|
|
3852
|
-
const
|
|
4193
|
+
const stored = readOwnerApiKey(options.home);
|
|
4194
|
+
const keyInfo = { source: options.apiKey ? 'environment' : null, path: null, owner_files_disagree: stored.conflict };
|
|
4195
|
+
if (!options.apiKey && stored.apiKey) {
|
|
4196
|
+
options.apiKey = stored.apiKey;
|
|
4197
|
+
keyInfo.source = 'owner_env_file';
|
|
4198
|
+
keyInfo.path = stored.source;
|
|
4199
|
+
}
|
|
4200
|
+
const oneCommand = Boolean((options.activate || options.update) && !options.dryRun && !options.doctor);
|
|
4201
|
+
if (oneCommand && !options.apiKey) {
|
|
4202
|
+
// An update aimed at an unmanaged directory is refused first; it needs no key to answer.
|
|
4203
|
+
if (options.update) {
|
|
4204
|
+
assertUpdateTargetsManagedRoot(detectEnvironment(options.cwd, {
|
|
4205
|
+
...process.env,
|
|
4206
|
+
HOME: options.home || options.cwd,
|
|
4207
|
+
USERPROFILE: options.home || options.cwd,
|
|
4208
|
+
}));
|
|
4209
|
+
}
|
|
4210
|
+
process.stdout.write(`Marrow needs your API key. Run: MARROW_API_KEY=<your key> ${installCommandFor(options)} (create a key at https://getmarrow.ai)\n`);
|
|
4211
|
+
process.exitCode = 2;
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
let report;
|
|
4215
|
+
try {
|
|
4216
|
+
report = await install(options);
|
|
4217
|
+
} catch (error) {
|
|
4218
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4219
|
+
if (!oneCommand || options.json || !/^Marrow activation failed/.test(message)) throw error;
|
|
4220
|
+
process.stderr.write(`Marrow ${INSTALLER_ADAPTER_VERSION}: ${message}. Fix: ${activationFailureFix(message)}\n`);
|
|
4221
|
+
process.exitCode = 1;
|
|
4222
|
+
return;
|
|
4223
|
+
}
|
|
3853
4224
|
if (options.json) {
|
|
3854
4225
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
3855
|
-
|
|
4226
|
+
return;
|
|
4227
|
+
}
|
|
4228
|
+
if (!oneCommand || options.verbose) {
|
|
3856
4229
|
printReport(report);
|
|
4230
|
+
return;
|
|
4231
|
+
}
|
|
4232
|
+
report.api_key = keyInfo;
|
|
4233
|
+
const { lines, selfTestFailed } = installSummaryLines(report, options);
|
|
4234
|
+
let full = '';
|
|
4235
|
+
printReport(report, (text) => { full += text; });
|
|
4236
|
+
let logPath = null;
|
|
4237
|
+
try {
|
|
4238
|
+
logPath = writeInstallLog(options.home, full);
|
|
4239
|
+
} catch {
|
|
4240
|
+
logPath = null;
|
|
3857
4241
|
}
|
|
4242
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
4243
|
+
if (logPath) process.stdout.write(`Full report: ${logPath}\n`);
|
|
4244
|
+
else process.stdout.write(`\n${full}`);
|
|
4245
|
+
if (selfTestFailed) process.exitCode = 1;
|
|
3858
4246
|
}
|
|
3859
4247
|
|
|
3860
4248
|
module.exports = {
|
|
@@ -3867,13 +4255,15 @@ module.exports = {
|
|
|
3867
4255
|
runLoopGuardSelfTest,
|
|
3868
4256
|
runCli,
|
|
3869
4257
|
passiveRuntimeSource,
|
|
3870
|
-
inspectNpmTokenConfig,
|
|
3871
4258
|
inspectSdkDependency,
|
|
3872
4259
|
inspectMcpProcesses,
|
|
3873
4260
|
inspectMcpConfigurations,
|
|
3874
4261
|
buildInstallValueMoment,
|
|
3875
4262
|
buildTokenValueProof,
|
|
3876
4263
|
stableAgentId,
|
|
4264
|
+
detectedClient,
|
|
4265
|
+
localControllerAgentId,
|
|
4266
|
+
identityAllowlist,
|
|
3877
4267
|
activationProfile,
|
|
3878
4268
|
claudeNativeHookFingerprint,
|
|
3879
4269
|
codexNativeHookFingerprint,
|
|
@@ -3891,6 +4281,7 @@ module.exports = {
|
|
|
3891
4281
|
CURSOR_NATIVE_HOOK_MATCHER,
|
|
3892
4282
|
GEMINI_NATIVE_HOOK_MATCHER,
|
|
3893
4283
|
printReport,
|
|
4284
|
+
installSummaryLines,
|
|
3894
4285
|
buildMcpToolProfileReport,
|
|
3895
4286
|
resolveMcpTargetVersion,
|
|
3896
4287
|
resolveToolProfile,
|