@getmarrow/install 0.1.64 → 0.1.66
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 +95 -21
- package/package.json +1 -1
- package/src/controller-manager.js +163 -11
- package/src/enforcement-client.js +34 -13
- package/src/first-hour.js +17 -3
- package/src/governance-sidecar.js +134 -18
- package/src/governed-runner.js +430 -99
- package/src/hermes-config.js +568 -0
- package/src/installer.js +875 -385
- 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.97';
|
|
27
|
+
const MCP_ADAPTER_SOURCE_SHA = 'be607e1dffd4d9e6a3c40f01151e509c115fba3f';
|
|
28
|
+
const MCP_ADAPTER_INTEGRITY = 'sha512-wab1kvgec8WhDDrpkajauLu2QdADXudIxkic0BAgzV96FZVHY0ME45hrOyQmqfkI+OtKriYdgIFp90peWGh2mA==';
|
|
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,12 +1050,63 @@ 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
|
};
|
|
969
1060
|
}
|
|
970
1061
|
|
|
1062
|
+
const MARROW_MANAGED_ROOT_FILES = Object.freeze([
|
|
1063
|
+
'AGENTS.md',
|
|
1064
|
+
'CLAUDE.md',
|
|
1065
|
+
'.mcp.json',
|
|
1066
|
+
path.join('.claude', 'settings.json'),
|
|
1067
|
+
path.join('.codex', 'hooks.json'),
|
|
1068
|
+
path.join('.cursor', 'hooks.json'),
|
|
1069
|
+
path.join('.cursor', 'mcp.json'),
|
|
1070
|
+
path.join('.cursor', 'rules', 'marrow.mdc'),
|
|
1071
|
+
path.join('.windsurf', 'hooks.json'),
|
|
1072
|
+
path.join('.gemini', 'settings.json'),
|
|
1073
|
+
path.join('.clinerules', 'hooks', 'PreToolUse'),
|
|
1074
|
+
path.join('.clinerules', 'hooks', 'PostToolUse'),
|
|
1075
|
+
path.join('.clinerules', 'hooks', 'TaskCancel'),
|
|
1076
|
+
path.join('.marrow', 'passive-runtime.mjs'),
|
|
1077
|
+
]);
|
|
1078
|
+
const MARROW_MANAGED_TEXT_RE = /@getmarrow\/mcp|marrow-mcp|<!-- marrow:passive-start -->/;
|
|
1079
|
+
|
|
1080
|
+
function marrowManagedRootFiles(root) {
|
|
1081
|
+
return MARROW_MANAGED_ROOT_FILES.filter((relative) => {
|
|
1082
|
+
try {
|
|
1083
|
+
const filePath = path.join(path.resolve(root), relative);
|
|
1084
|
+
const stat = fs.lstatSync(filePath);
|
|
1085
|
+
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return false;
|
|
1086
|
+
return relative === path.join('.marrow', 'passive-runtime.mjs')
|
|
1087
|
+
|| MARROW_MANAGED_TEXT_RE.test(fs.readFileSync(filePath, 'utf8'));
|
|
1088
|
+
} catch {
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function shellArgument(value) {
|
|
1095
|
+
return /^[A-Za-z0-9_./:@%+=-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function assertUpdateTargetsManagedRoot(detection) {
|
|
1099
|
+
const root = path.resolve(detection.root);
|
|
1100
|
+
const home = path.resolve(detection.home);
|
|
1101
|
+
if (root === home || marrowManagedRootFiles(root).length > 0) return;
|
|
1102
|
+
if (marrowManagedRootFiles(home).length === 0) return;
|
|
1103
|
+
throw new Error([
|
|
1104
|
+
`Refusing to update ${root}: it has no Marrow-managed files, and update never creates new Marrow project files in an unmanaged directory.`,
|
|
1105
|
+
`Marrow-managed configuration was found at ${home}. To update it, run: ${INSTALLER_UPDATE_COMMAND} --cwd ${shellArgument(home)}`,
|
|
1106
|
+
`To install Marrow into ${root} deliberately, run the install command from that directory instead: npx -y @getmarrow/install@latest`,
|
|
1107
|
+
].join('\n'));
|
|
1108
|
+
}
|
|
1109
|
+
|
|
971
1110
|
function findLikelyEnvFiles(detection, env = process.env) {
|
|
972
1111
|
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
973
1112
|
const candidates = [
|
|
@@ -976,8 +1115,6 @@ function findLikelyEnvFiles(detection, env = process.env) {
|
|
|
976
1115
|
path.join(detection.root, '.marrow', 'env'),
|
|
977
1116
|
path.join(detection.root, '.marrow', 'env.local'),
|
|
978
1117
|
path.join(home, '.marrow', 'env'),
|
|
979
|
-
path.join(home, '.openclaw', 'credentials', 'marrow-mcp.env'),
|
|
980
|
-
path.join(home, '.openclaw', 'gateway.systemd.env'),
|
|
981
1118
|
];
|
|
982
1119
|
return candidates.filter((filePath) => {
|
|
983
1120
|
if (!exists(filePath)) return false;
|
|
@@ -986,176 +1123,6 @@ function findLikelyEnvFiles(detection, env = process.env) {
|
|
|
986
1123
|
});
|
|
987
1124
|
}
|
|
988
1125
|
|
|
989
|
-
function stripQuotes(value) {
|
|
990
|
-
const trimmed = String(value || '').trim();
|
|
991
|
-
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
992
|
-
return trimmed.slice(1, -1);
|
|
993
|
-
}
|
|
994
|
-
return trimmed;
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
function readEnvVar(filePath, name) {
|
|
998
|
-
if (!exists(filePath)) return '';
|
|
999
|
-
const raw = safeRead(filePath);
|
|
1000
|
-
const pattern = new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=\\s*([^\\n#]+)`, 'm');
|
|
1001
|
-
const match = raw.match(pattern);
|
|
1002
|
-
return match ? stripQuotes(match[1]) : '';
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
function readFirstLineSecret(filePath) {
|
|
1006
|
-
if (!exists(filePath)) return '';
|
|
1007
|
-
return safeRead(filePath).split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
function readNpmrcToken(filePath) {
|
|
1011
|
-
if (!exists(filePath)) return '';
|
|
1012
|
-
const raw = safeRead(filePath);
|
|
1013
|
-
const match = raw.match(/\/\/registry\.npmjs\.org\/:_authToken\s*=\s*([^\s]+)/);
|
|
1014
|
-
return match ? stripQuotes(match[1]) : '';
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function fingerprint(value) {
|
|
1018
|
-
const secret = String(value || '').trim();
|
|
1019
|
-
if (!secret) return null;
|
|
1020
|
-
return crypto.createHash('sha256').update(secret).digest('hex').slice(0, 12);
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
function npmTokenPaths(env = process.env) {
|
|
1024
|
-
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
1025
|
-
return {
|
|
1026
|
-
home,
|
|
1027
|
-
openclawEnv: path.join(home, '.openclaw', '.env'),
|
|
1028
|
-
credentialFile: path.join(home, '.openclaw', 'credentials', 'npm-getmarrow-token.txt'),
|
|
1029
|
-
npmrc: path.join(home, '.npmrc'),
|
|
1030
|
-
};
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
function inspectNpmTokenConfig(env = process.env) {
|
|
1034
|
-
const paths = npmTokenPaths(env);
|
|
1035
|
-
const openclawToken = readEnvVar(paths.openclawEnv, 'NPM_TOKEN');
|
|
1036
|
-
const credentialToken = readFirstLineSecret(paths.credentialFile);
|
|
1037
|
-
let npmrcToken = '';
|
|
1038
|
-
let unsafeNpmrcPath = false;
|
|
1039
|
-
try {
|
|
1040
|
-
assertDirectOwnerFile(paths.home, paths.npmrc, { allowMissing: true });
|
|
1041
|
-
npmrcToken = readNpmrcToken(paths.npmrc);
|
|
1042
|
-
} catch {
|
|
1043
|
-
unsafeNpmrcPath = true;
|
|
1044
|
-
}
|
|
1045
|
-
const sourceToken = openclawToken || credentialToken;
|
|
1046
|
-
const mismatch = Boolean(sourceToken && npmrcToken && fingerprint(sourceToken) !== fingerprint(npmrcToken));
|
|
1047
|
-
const missingNpmrcToken = Boolean(sourceToken && !npmrcToken);
|
|
1048
|
-
|
|
1049
|
-
return {
|
|
1050
|
-
safe: {
|
|
1051
|
-
npm_token: {
|
|
1052
|
-
checked: true,
|
|
1053
|
-
repairable: Boolean(sourceToken && (mismatch || missingNpmrcToken) && !unsafeNpmrcPath),
|
|
1054
|
-
mismatch,
|
|
1055
|
-
missing_npmrc_token: missingNpmrcToken,
|
|
1056
|
-
unsafe_path: unsafeNpmrcPath,
|
|
1057
|
-
sources: {
|
|
1058
|
-
openclaw_env: { path: paths.openclawEnv, present: Boolean(openclawToken), fingerprint: fingerprint(openclawToken) },
|
|
1059
|
-
credential_file: { path: paths.credentialFile, present: Boolean(credentialToken), fingerprint: fingerprint(credentialToken) },
|
|
1060
|
-
npmrc: { path: paths.npmrc, present: Boolean(npmrcToken), fingerprint: fingerprint(npmrcToken) },
|
|
1061
|
-
},
|
|
1062
|
-
recommended_fix: unsafeNpmrcPath
|
|
1063
|
-
? 'Refusing automatic npm token repair because ~/.npmrc or its home directory is not a direct, regular owner path.'
|
|
1064
|
-
: mismatch || missingNpmrcToken
|
|
1065
|
-
? 'Run npx @getmarrow/install --repair to sync ~/.npmrc from the active OpenClaw/getmarrow npm token source.'
|
|
1066
|
-
: null,
|
|
1067
|
-
},
|
|
1068
|
-
},
|
|
1069
|
-
raw: { paths, sourceToken, npmrcToken },
|
|
1070
|
-
};
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
function assertDirectOwnerFile(homePath, filePath, { allowMissing = false } = {}) {
|
|
1074
|
-
const home = path.resolve(homePath);
|
|
1075
|
-
const target = path.resolve(filePath);
|
|
1076
|
-
if (target !== path.join(home, '.npmrc')) throw new Error('npm token repair target must be the direct owner ~/.npmrc');
|
|
1077
|
-
if (!fs.existsSync(home)) throw new Error('npm token repair owner home does not exist');
|
|
1078
|
-
const homeStat = fs.lstatSync(home);
|
|
1079
|
-
if (!homeStat.isDirectory() || homeStat.isSymbolicLink() || fs.realpathSync(home) !== home) {
|
|
1080
|
-
throw new Error('npm token repair owner home must be a direct, non-symbolic directory');
|
|
1081
|
-
}
|
|
1082
|
-
if (!fs.existsSync(target)) {
|
|
1083
|
-
if (allowMissing) return;
|
|
1084
|
-
throw new Error('npm token repair target does not exist');
|
|
1085
|
-
}
|
|
1086
|
-
const targetStat = fs.lstatSync(target);
|
|
1087
|
-
if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
|
|
1088
|
-
throw new Error('npm token repair target must be a regular file, not a symbolic link');
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
function atomicWriteOwnerFile(homePath, filePath, contents) {
|
|
1093
|
-
const home = path.resolve(homePath);
|
|
1094
|
-
const target = path.resolve(filePath);
|
|
1095
|
-
const allowMissing = !fs.existsSync(target);
|
|
1096
|
-
assertDirectOwnerFile(home, target, { allowMissing });
|
|
1097
|
-
const tempPath = path.join(home, `.npmrc.marrow-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
1098
|
-
let descriptor;
|
|
1099
|
-
try {
|
|
1100
|
-
descriptor = fs.openSync(tempPath, 'wx', 0o600);
|
|
1101
|
-
fs.writeFileSync(descriptor, contents, { encoding: 'utf8' });
|
|
1102
|
-
fs.fsyncSync(descriptor);
|
|
1103
|
-
fs.closeSync(descriptor);
|
|
1104
|
-
descriptor = undefined;
|
|
1105
|
-
assertDirectOwnerFile(home, target, { allowMissing });
|
|
1106
|
-
fs.renameSync(tempPath, target);
|
|
1107
|
-
fs.chmodSync(target, 0o600);
|
|
1108
|
-
} finally {
|
|
1109
|
-
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
1110
|
-
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
function upsertNpmrcToken(homePath, filePath, token) {
|
|
1115
|
-
assertDirectOwnerFile(homePath, filePath, { allowMissing: true });
|
|
1116
|
-
const before = safeRead(filePath);
|
|
1117
|
-
const tokenLine = `//registry.npmjs.org/:_authToken=${token}`;
|
|
1118
|
-
let after;
|
|
1119
|
-
if (/\/\/registry\.npmjs\.org\/:_authToken\s*=/.test(before)) {
|
|
1120
|
-
after = before.replace(/\/\/registry\.npmjs\.org\/:_authToken\s*=\s*[^\n\r]+/, tokenLine);
|
|
1121
|
-
} else {
|
|
1122
|
-
const separator = before && !before.endsWith('\n') ? '\n' : '';
|
|
1123
|
-
after = `${before}${separator}${tokenLine}\n`;
|
|
1124
|
-
}
|
|
1125
|
-
if (before !== after) {
|
|
1126
|
-
if (before) {
|
|
1127
|
-
const backupPath = `${filePath}.marrow-backup`;
|
|
1128
|
-
if (fs.existsSync(backupPath) && fs.lstatSync(backupPath).isSymbolicLink()) {
|
|
1129
|
-
throw new Error('npm token repair backup must not be a symbolic link');
|
|
1130
|
-
}
|
|
1131
|
-
const backupTemp = path.join(path.resolve(homePath), `.npmrc.marrow-backup-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
1132
|
-
fs.writeFileSync(backupTemp, before, { mode: 0o600, flag: 'wx' });
|
|
1133
|
-
fs.renameSync(backupTemp, backupPath);
|
|
1134
|
-
fs.chmodSync(backupPath, 0o600);
|
|
1135
|
-
}
|
|
1136
|
-
atomicWriteOwnerFile(homePath, filePath, after);
|
|
1137
|
-
}
|
|
1138
|
-
return before !== after;
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
function repairConfigDiagnostics(diagnostics, env = process.env) {
|
|
1142
|
-
const inspection = inspectNpmTokenConfig(env);
|
|
1143
|
-
const npm = diagnostics.npm_token;
|
|
1144
|
-
const repairs = [];
|
|
1145
|
-
if (npm?.repairable && inspection.raw.sourceToken) {
|
|
1146
|
-
const changed = upsertNpmrcToken(inspection.raw.paths.home, inspection.raw.paths.npmrc, inspection.raw.sourceToken);
|
|
1147
|
-
repairs.push({
|
|
1148
|
-
type: 'npm_token_npmrc_sync',
|
|
1149
|
-
changed,
|
|
1150
|
-
path: inspection.raw.paths.npmrc,
|
|
1151
|
-
message: changed
|
|
1152
|
-
? 'Synced ~/.npmrc npm token from active OpenClaw/getmarrow token source.'
|
|
1153
|
-
: '~/.npmrc already matched the active OpenClaw/getmarrow token source.',
|
|
1154
|
-
});
|
|
1155
|
-
}
|
|
1156
|
-
return repairs;
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
1126
|
function passiveInstructions() {
|
|
1160
1127
|
return `${MARROW_BLOCK_START}
|
|
1161
1128
|
## Marrow Passive Agent Memory
|
|
@@ -1196,7 +1163,7 @@ if (apiKey && !globalThis.__MARROW_PASSIVE_RUNTIME__) {
|
|
|
1196
1163
|
const { MarrowClient } = await import('@getmarrow/sdk');
|
|
1197
1164
|
const marrow = new MarrowClient(apiKey, {
|
|
1198
1165
|
baseUrl: installedBaseUrl,
|
|
1199
|
-
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,
|
|
1200
1167
|
sessionId: process.env.MARROW_SESSION_ID,
|
|
1201
1168
|
mode: process.env.MARROW_ENFORCEMENT_MODE || 'auto',
|
|
1202
1169
|
});
|
|
@@ -1220,12 +1187,15 @@ if (apiKey && !globalThis.__MARROW_PASSIVE_RUNTIME__) {
|
|
|
1220
1187
|
}
|
|
1221
1188
|
|
|
1222
1189
|
function envExample(options = {}) {
|
|
1223
|
-
const agentId = String(options.agentId || '
|
|
1190
|
+
const agentId = String(options.agentId || '').trim();
|
|
1224
1191
|
const client = String(options.client || 'custom').trim() || 'custom';
|
|
1225
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.';
|
|
1226
1196
|
return `MARROW_API_KEY=mrw_live_replace_me
|
|
1227
1197
|
MARROW_BASE_URL=${JSON.stringify(baseUrl)}
|
|
1228
|
-
|
|
1198
|
+
${agentLine}
|
|
1229
1199
|
MARROW_CLIENT=${JSON.stringify(client)}
|
|
1230
1200
|
# MARROW_TOOL_PROFILE is intentionally unset: ordinary setup uses primary. Set core or full only as an explicit opt-in.
|
|
1231
1201
|
MARROW_ENFORCEMENT_MODE=auto
|
|
@@ -1248,6 +1218,28 @@ function parseJsonObject(filePath) {
|
|
|
1248
1218
|
return parsed;
|
|
1249
1219
|
}
|
|
1250
1220
|
|
|
1221
|
+
function canonicalJsonValue(value) {
|
|
1222
|
+
if (Array.isArray(value)) return value.map(canonicalJsonValue);
|
|
1223
|
+
if (!value || typeof value !== 'object') return value;
|
|
1224
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalJsonValue(value[key])]));
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function sameJsonDocument(left, right) {
|
|
1228
|
+
try {
|
|
1229
|
+
return JSON.stringify(canonicalJsonValue(JSON.parse(left.trim())))
|
|
1230
|
+
=== JSON.stringify(canonicalJsonValue(JSON.parse(right.trim())));
|
|
1231
|
+
} catch {
|
|
1232
|
+
return false;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// JSON.parse keeps the last duplicate key, so equal documents keep their bytes only when the
|
|
1237
|
+
// raw text also pins the same MCP versions; otherwise a stale earlier duplicate is never repaired.
|
|
1238
|
+
function equivalentManagedJson(before, after) {
|
|
1239
|
+
const pinnedVersions = (text) => [...new Set(mcpVersionsInText(text))].sort().join(',');
|
|
1240
|
+
return sameJsonDocument(before, after) && pinnedVersions(before) === pinnedVersions(after);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1251
1243
|
function upsertBlock(content, block) {
|
|
1252
1244
|
if (content.includes(MARROW_BLOCK_START) && content.includes(MARROW_BLOCK_END)) {
|
|
1253
1245
|
const start = content.indexOf(MARROW_BLOCK_START);
|
|
@@ -1301,14 +1293,24 @@ function exactHookDescriptors(settings, eventName, command, matcher) {
|
|
|
1301
1293
|
function marrowHookSubcommand(command) {
|
|
1302
1294
|
if (typeof command !== 'string') return null;
|
|
1303
1295
|
const match = command.trim().match(
|
|
1304
|
-
/^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)$/,
|
|
1305
1297
|
);
|
|
1306
1298
|
return match?.[1] || null;
|
|
1307
1299
|
}
|
|
1308
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
|
+
|
|
1309
1310
|
function reconcileMarrowCommandHook(settings, eventName, subcommand, command, matcher, handlerOptions = {}) {
|
|
1310
1311
|
const original = Array.isArray(settings?.hooks?.[eventName]) ? settings.hooks[eventName] : [];
|
|
1311
1312
|
let preferredHandler = null;
|
|
1313
|
+
let preferredRank = 0;
|
|
1312
1314
|
const retained = [];
|
|
1313
1315
|
for (const entry of original) {
|
|
1314
1316
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !Array.isArray(entry.hooks)) {
|
|
@@ -1321,8 +1323,10 @@ function reconcileMarrowCommandHook(settings, eventName, subcommand, command, ma
|
|
|
1321
1323
|
&& hook.type === 'command' ? marrowHookSubcommand(hook.command) : null;
|
|
1322
1324
|
if (detected) {
|
|
1323
1325
|
const exactMatcher = matcher == null ? entry.matcher === undefined : entry.matcher === matcher;
|
|
1324
|
-
|
|
1326
|
+
const rank = detected === subcommand ? marrowHandlerRank(hook, command, exactMatcher) : 0;
|
|
1327
|
+
if (rank > preferredRank) {
|
|
1325
1328
|
preferredHandler = hook;
|
|
1329
|
+
preferredRank = rank;
|
|
1326
1330
|
}
|
|
1327
1331
|
continue;
|
|
1328
1332
|
}
|
|
@@ -2072,7 +2076,7 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
2072
2076
|
? `${sdkDependency.install_command} && npx @getmarrow/install --repair`
|
|
2073
2077
|
: sdkDependency.warning
|
|
2074
2078
|
: capabilityLevel === 'governed_wrapper'
|
|
2075
|
-
? `npx @getmarrow/install run --
|
|
2079
|
+
? `npx @getmarrow/install run -- ${client}`
|
|
2076
2080
|
: client === 'cline' && clineConflicts.length > 0
|
|
2077
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.'
|
|
2078
2082
|
: client === 'gemini' && geminiHooksExplicitlyDisabled(geminiSettings)
|
|
@@ -2130,21 +2134,82 @@ function activationProfile(detection, plan, changes, client) {
|
|
|
2130
2134
|
};
|
|
2131
2135
|
}
|
|
2132
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.
|
|
2133
2167
|
function upsertMcpServerConfig(filePath, options = {}) {
|
|
2134
2168
|
const agentId = String(options.agentId || '').trim();
|
|
2135
|
-
|
|
2136
|
-
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 : () => {};
|
|
2137
2172
|
const config = parseJsonObject(filePath);
|
|
2138
2173
|
const servers = config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)
|
|
2139
2174
|
? config.mcpServers
|
|
2140
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
|
+
}
|
|
2141
2208
|
const existingProfile = resolveToolProfile(servers.marrow?.env?.MARROW_TOOL_PROFILE).configured_profile;
|
|
2142
2209
|
const requestedProfile = resolveToolProfile(options.toolProfile).configured_profile;
|
|
2143
2210
|
const configuredProfile = requestedProfile === 'unset' ? existingProfile : requestedProfile;
|
|
2144
|
-
const env = {
|
|
2145
|
-
|
|
2146
|
-
MARROW_FLEET_AGENT_ID: agentId,
|
|
2147
|
-
};
|
|
2211
|
+
const env = { MARROW_BASE_URL: baseUrl };
|
|
2212
|
+
if (keptAgentId) env.MARROW_FLEET_AGENT_ID = keptAgentId;
|
|
2148
2213
|
if (configuredProfile !== 'unset') env.MARROW_TOOL_PROFILE = configuredProfile;
|
|
2149
2214
|
servers.marrow = {
|
|
2150
2215
|
command: 'npx',
|
|
@@ -2382,10 +2447,46 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
|
|
|
2382
2447
|
});
|
|
2383
2448
|
}
|
|
2384
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
|
+
|
|
2385
2465
|
function buildPlan(detection, options) {
|
|
2386
2466
|
const client = options.client || detectedClient(detection);
|
|
2387
|
-
|
|
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);
|
|
2388
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
|
+
};
|
|
2389
2490
|
const mcpTargetVersion = executableMcpTarget(options).version;
|
|
2390
2491
|
const retarget = (value) => retargetMcpPackageSpec(value, mcpTargetVersion);
|
|
2391
2492
|
const mode = options.mode === 'auto'
|
|
@@ -2465,12 +2566,22 @@ function buildPlan(detection, options) {
|
|
|
2465
2566
|
transform: (filePath) => retarget(upsertGrokHooks(filePath)),
|
|
2466
2567
|
});
|
|
2467
2568
|
}
|
|
2468
|
-
writes.push(
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
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
|
+
}
|
|
2474
2585
|
if (detection.cursor) {
|
|
2475
2586
|
writes.push({
|
|
2476
2587
|
type: 'json-transform',
|
|
@@ -2478,12 +2589,7 @@ function buildPlan(detection, options) {
|
|
|
2478
2589
|
label: 'Cursor native hooks',
|
|
2479
2590
|
transform: (filePath) => retarget(upsertCursorHooks(filePath)),
|
|
2480
2591
|
});
|
|
2481
|
-
writes.push(
|
|
2482
|
-
type: 'json-transform',
|
|
2483
|
-
path: detection.paths.cursorMcp,
|
|
2484
|
-
label: 'Cursor MCP server config',
|
|
2485
|
-
transform: (filePath) => retarget(upsertMcpServerConfig(filePath, { agentId, baseUrl, toolProfile: options.toolProfile })),
|
|
2486
|
-
});
|
|
2592
|
+
writes.push(mcpConfigWrite(detection.paths.cursorMcp, 'Cursor MCP server config'));
|
|
2487
2593
|
}
|
|
2488
2594
|
}
|
|
2489
2595
|
|
|
@@ -2586,6 +2692,9 @@ function applyPlan(plan, options) {
|
|
|
2586
2692
|
const automaticRepairSuppressed = aheadUnverifiedVersions.length > 0;
|
|
2587
2693
|
let after;
|
|
2588
2694
|
let hookConflict = false;
|
|
2695
|
+
let conflictReason = null;
|
|
2696
|
+
let hostedEntry = false;
|
|
2697
|
+
let undo = null;
|
|
2589
2698
|
if (automaticRepairSuppressed) {
|
|
2590
2699
|
after = before;
|
|
2591
2700
|
} else if (write.type === 'file') {
|
|
@@ -2598,12 +2707,25 @@ function applyPlan(plan, options) {
|
|
|
2598
2707
|
after = upsertBlock(before, write.block);
|
|
2599
2708
|
} else if (write.type === 'json-transform') {
|
|
2600
2709
|
after = write.transform(write.path);
|
|
2710
|
+
if (equivalentManagedJson(before, after)) after = before;
|
|
2601
2711
|
} else if (write.type === 'managed-json-transform') {
|
|
2602
2712
|
if (fileExists && !write.isManaged(write.path)) {
|
|
2603
2713
|
after = before;
|
|
2604
2714
|
hookConflict = true;
|
|
2605
2715
|
} else {
|
|
2606
2716
|
after = write.transform(write.path);
|
|
2717
|
+
if (equivalentManagedJson(before, after)) after = before;
|
|
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;
|
|
2607
2729
|
}
|
|
2608
2730
|
} else if (write.type === 'owned-executable') {
|
|
2609
2731
|
if (fileExists && before !== write.content) {
|
|
@@ -2623,6 +2745,9 @@ function applyPlan(plan, options) {
|
|
|
2623
2745
|
before,
|
|
2624
2746
|
after,
|
|
2625
2747
|
hookConflict,
|
|
2748
|
+
conflictReason,
|
|
2749
|
+
hostedEntry,
|
|
2750
|
+
undo,
|
|
2626
2751
|
modeChanged,
|
|
2627
2752
|
automaticRepairSuppressed,
|
|
2628
2753
|
aheadUnverifiedVersions,
|
|
@@ -2635,6 +2760,9 @@ function applyPlan(plan, options) {
|
|
|
2635
2760
|
before,
|
|
2636
2761
|
after,
|
|
2637
2762
|
hookConflict,
|
|
2763
|
+
conflictReason,
|
|
2764
|
+
hostedEntry,
|
|
2765
|
+
undo,
|
|
2638
2766
|
modeChanged,
|
|
2639
2767
|
automaticRepairSuppressed,
|
|
2640
2768
|
aheadUnverifiedVersions,
|
|
@@ -2655,7 +2783,14 @@ function applyPlan(plan, options) {
|
|
|
2655
2783
|
ahead_unverified_versions: aheadUnverifiedVersions,
|
|
2656
2784
|
exact_fix: mcpRegistryVerificationAction(aheadUnverifiedVersions),
|
|
2657
2785
|
} : {}),
|
|
2658
|
-
...(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 } : {}),
|
|
2659
2794
|
});
|
|
2660
2795
|
if (contentChanged && writeApplied) {
|
|
2661
2796
|
atomicWriteManagedFile(path.resolve(write.root || root), write.path, after);
|
|
@@ -2668,8 +2803,10 @@ function applyPlan(plan, options) {
|
|
|
2668
2803
|
return changes;
|
|
2669
2804
|
}
|
|
2670
2805
|
|
|
2806
|
+
const SELF_TEST_READ_TIMEOUT_MS = 15_000;
|
|
2807
|
+
|
|
2671
2808
|
async function requestJson(url, options) {
|
|
2672
|
-
const res = await fetch(url, options);
|
|
2809
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(SELF_TEST_READ_TIMEOUT_MS), ...options });
|
|
2673
2810
|
const text = await res.text();
|
|
2674
2811
|
let json = {};
|
|
2675
2812
|
try {
|
|
@@ -2688,6 +2825,8 @@ const SELF_TEST_WRITE_ATTEMPTS = 3;
|
|
|
2688
2825
|
const SELF_TEST_RETRY_DELAY_MS = 1_000;
|
|
2689
2826
|
const SELF_TEST_MAX_RETRY_DELAY_MS = 2_000;
|
|
2690
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;
|
|
2691
2830
|
|
|
2692
2831
|
function selfTestRetryDelayMs(response, data, override) {
|
|
2693
2832
|
if (Number.isFinite(override) && override >= 0) return override;
|
|
@@ -2697,13 +2836,33 @@ function selfTestRetryDelayMs(response, data, override) {
|
|
|
2697
2836
|
return Math.min(delay, SELF_TEST_MAX_RETRY_DELAY_MS);
|
|
2698
2837
|
}
|
|
2699
2838
|
|
|
2700
|
-
// A first write can meet a transient 5xx, or a durable "pending" acknowledgement
|
|
2701
|
-
//
|
|
2702
|
-
//
|
|
2703
|
-
|
|
2839
|
+
// A first write can meet a transient 5xx, or a durable "pending" acknowledgement.
|
|
2840
|
+
// Marrow's contract is to resend the identical request with the same Idempotency-Key,
|
|
2841
|
+
// so a retry can never create a second record. As in the MCP client, a pending
|
|
2842
|
+
// acknowledgement is never complete, even when it names a created decision: that id
|
|
2843
|
+
// is pinned and the final response must carry the same one.
|
|
2844
|
+
async function selfTestWrite(url, options, { idempotencyKey, complete, retryDelayMs, attemptTimeoutMs, deadlineMs }) {
|
|
2704
2845
|
let lastState = 'no response';
|
|
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);
|
|
2705
2849
|
for (let attempt = 1; attempt <= SELF_TEST_WRITE_ATTEMPTS; attempt += 1) {
|
|
2706
|
-
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
|
+
}
|
|
2707
2866
|
const text = await res.text();
|
|
2708
2867
|
let json = {};
|
|
2709
2868
|
try {
|
|
@@ -2713,10 +2872,18 @@ async function selfTestWrite(url, options, { idempotencyKey, complete, retryDela
|
|
|
2713
2872
|
}
|
|
2714
2873
|
const data = json.data || json;
|
|
2715
2874
|
if (res.ok) {
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2875
|
+
if (data?.idempotency_key !== undefined && data.idempotency_key !== idempotencyKey) {
|
|
2876
|
+
throw new Error('self-test write returned a different idempotency key');
|
|
2877
|
+
}
|
|
2878
|
+
const decisionId = selfTestDecisionId(data);
|
|
2879
|
+
if (pinnedDecisionId && decisionId && decisionId !== pinnedDecisionId) {
|
|
2880
|
+
throw new Error('self-test write returned a different decision id than its pending acknowledgement');
|
|
2881
|
+
}
|
|
2882
|
+
if (data?.retryable !== true || data?.committed !== false) return complete(data);
|
|
2883
|
+
if (data.idempotency_key !== idempotencyKey) {
|
|
2884
|
+
throw new Error('self-test write returned a pending acknowledgement without its idempotency key');
|
|
2885
|
+
}
|
|
2886
|
+
if (decisionId && data.decision_state === 'created') pinnedDecisionId = decisionId;
|
|
2720
2887
|
lastState = 'pending';
|
|
2721
2888
|
} else if (SELF_TEST_TRANSIENT_STATUSES.has(res.status)) {
|
|
2722
2889
|
const reason = String(json.error || json.message || '').slice(0, 200);
|
|
@@ -2725,7 +2892,8 @@ async function selfTestWrite(url, options, { idempotencyKey, complete, retryDela
|
|
|
2725
2892
|
throw new Error(String(json.error || json.message || `HTTP ${res.status}`));
|
|
2726
2893
|
}
|
|
2727
2894
|
if (attempt < SELF_TEST_WRITE_ATTEMPTS) {
|
|
2728
|
-
|
|
2895
|
+
const wait = Math.min(selfTestRetryDelayMs(res, data, retryDelayMs), Math.max(0, deadline - Date.now()));
|
|
2896
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
2729
2897
|
}
|
|
2730
2898
|
}
|
|
2731
2899
|
return { failed: lastState };
|
|
@@ -2733,9 +2901,7 @@ async function selfTestWrite(url, options, { idempotencyKey, complete, retryDela
|
|
|
2733
2901
|
|
|
2734
2902
|
function selfTestDecisionId(data) {
|
|
2735
2903
|
const decisionId = data?.decision_id || data?.decisionId;
|
|
2736
|
-
|
|
2737
|
-
const pending = data.retryable === true && data.committed === false;
|
|
2738
|
-
return !pending || data.decision_state === 'created' ? decisionId : undefined;
|
|
2904
|
+
return typeof decisionId === 'string' && decisionId ? decisionId : undefined;
|
|
2739
2905
|
}
|
|
2740
2906
|
|
|
2741
2907
|
function runLoopGuardSelfTest(options = {}) {
|
|
@@ -2810,6 +2976,61 @@ function runtimeGateVerified(runtime) {
|
|
|
2810
2976
|
return ['allow', 'warn', 'review_required', 'block'].includes(decision);
|
|
2811
2977
|
}
|
|
2812
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
|
+
|
|
2813
3034
|
async function runSelfTest(options) {
|
|
2814
3035
|
const initialProfile = initialToolProfileReport(options.toolProfile);
|
|
2815
3036
|
if (!options.selfTest) return { skipped: true, reason: 'disabled', mcp_tool_profile: initialProfile };
|
|
@@ -2833,7 +3054,10 @@ async function runSelfTest(options) {
|
|
|
2833
3054
|
'x-marrow-sdk-version': SDK_ADAPTER_VERSION,
|
|
2834
3055
|
'x-marrow-mcp-version': executableMcpTarget(options).version,
|
|
2835
3056
|
};
|
|
2836
|
-
|
|
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;
|
|
2837
3061
|
|
|
2838
3062
|
const baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
2839
3063
|
const selfTestKey = `install-self-test:${crypto.randomUUID()}`;
|
|
@@ -2853,6 +3077,8 @@ async function runSelfTest(options) {
|
|
|
2853
3077
|
idempotencyKey: `${selfTestKey}:think`,
|
|
2854
3078
|
complete: (data) => selfTestDecisionId(data),
|
|
2855
3079
|
retryDelayMs: options.selfTestRetryDelayMs,
|
|
3080
|
+
attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
|
|
3081
|
+
deadlineMs: options.selfTestDeadlineMs,
|
|
2856
3082
|
});
|
|
2857
3083
|
|
|
2858
3084
|
const decisionId = typeof think === 'string' ? think : undefined;
|
|
@@ -2870,8 +3096,10 @@ async function runSelfTest(options) {
|
|
|
2870
3096
|
}),
|
|
2871
3097
|
}, {
|
|
2872
3098
|
idempotencyKey: `${selfTestKey}:commit`,
|
|
2873
|
-
complete: (data) =>
|
|
3099
|
+
complete: (data) => data,
|
|
2874
3100
|
retryDelayMs: options.selfTestRetryDelayMs,
|
|
3101
|
+
attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
|
|
3102
|
+
deadlineMs: options.selfTestDeadlineMs,
|
|
2875
3103
|
});
|
|
2876
3104
|
if (commit?.failed) {
|
|
2877
3105
|
throw new Error(`self-test commit did not complete after ${SELF_TEST_WRITE_ATTEMPTS} attempts (last: ${commit.failed})`);
|
|
@@ -2886,7 +3114,7 @@ async function runSelfTest(options) {
|
|
|
2886
3114
|
context?.primary_tool_availability,
|
|
2887
3115
|
Boolean(options.activation),
|
|
2888
3116
|
);
|
|
2889
|
-
const runtime = await
|
|
3117
|
+
const runtime = await selfTestWrite(`${baseUrl}/v1/agent/runtime`, {
|
|
2890
3118
|
method: 'POST',
|
|
2891
3119
|
headers,
|
|
2892
3120
|
body: JSON.stringify({
|
|
@@ -2900,13 +3128,26 @@ async function runSelfTest(options) {
|
|
|
2900
3128
|
outcome: 'self-test outcome committed',
|
|
2901
3129
|
},
|
|
2902
3130
|
}),
|
|
3131
|
+
}, {
|
|
3132
|
+
idempotencyKey: `${selfTestKey}:runtime`,
|
|
3133
|
+
complete: (data) => data,
|
|
3134
|
+
retryDelayMs: options.selfTestRetryDelayMs,
|
|
3135
|
+
attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
|
|
3136
|
+
deadlineMs: options.selfTestDeadlineMs,
|
|
2903
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);
|
|
2904
3145
|
const performance = await requestJson(`${baseUrl}/v1/analytics/agent-performance?period=7`, { headers })
|
|
2905
3146
|
.catch((error) => ({
|
|
2906
3147
|
ok: false,
|
|
2907
3148
|
error: error instanceof Error ? error.message : String(error),
|
|
2908
3149
|
}));
|
|
2909
|
-
const
|
|
3150
|
+
const firstValueResult = await selfTestWrite(`${baseUrl}/v1/agent/first-value`, {
|
|
2910
3151
|
method: 'POST',
|
|
2911
3152
|
headers,
|
|
2912
3153
|
body: JSON.stringify({
|
|
@@ -2919,14 +3160,25 @@ async function runSelfTest(options) {
|
|
|
2919
3160
|
outcome: 'first-value endpoint reached',
|
|
2920
3161
|
},
|
|
2921
3162
|
decision_id: decisionId,
|
|
2922
|
-
agent_id:
|
|
2923
|
-
activation: options.activation ? {
|
|
3163
|
+
agent_id: activationAgentId || undefined,
|
|
3164
|
+
activation: options.activation && activationAgentId ? {
|
|
2924
3165
|
...options.activation,
|
|
3166
|
+
agent_id: activationAgentId,
|
|
2925
3167
|
intervention_verified: runtimeGateVerified(runtime),
|
|
2926
3168
|
closure_verified: true,
|
|
2927
3169
|
} : undefined,
|
|
2928
3170
|
}),
|
|
3171
|
+
}, {
|
|
3172
|
+
idempotencyKey: `${selfTestKey}:first-value`,
|
|
3173
|
+
complete: (data) => data,
|
|
3174
|
+
retryDelayMs: options.selfTestRetryDelayMs,
|
|
3175
|
+
attemptTimeoutMs: options.selfTestAttemptTimeoutMs,
|
|
3176
|
+
deadlineMs: options.selfTestDeadlineMs,
|
|
2929
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;
|
|
2930
3182
|
const valueProof = await requestJson(`${baseUrl}/v1/agent/value/proof?period_days=30`, { headers })
|
|
2931
3183
|
.catch((error) => ({
|
|
2932
3184
|
ok: false,
|
|
@@ -2938,7 +3190,7 @@ async function runSelfTest(options) {
|
|
|
2938
3190
|
let activationReceipt = null;
|
|
2939
3191
|
let activationVerified = false;
|
|
2940
3192
|
let activationProfileReceipt = null;
|
|
2941
|
-
if (options.activation) {
|
|
3193
|
+
if (options.activation && !activationIdentityUnresolved) {
|
|
2942
3194
|
const activationAdapterVersion = options.activation.adapter_version || INSTALLER_ADAPTER_VERSION;
|
|
2943
3195
|
const activationCapabilityLevel = options.activation.capability_level || 'event_contract';
|
|
2944
3196
|
const activationExpectedHooks = Array.isArray(options.activation.expected_hooks) ? options.activation.expected_hooks : [];
|
|
@@ -2956,7 +3208,7 @@ async function runSelfTest(options) {
|
|
|
2956
3208
|
&& typeof activationReceipt.id === 'string'
|
|
2957
3209
|
&& activationReceipt.id.length > 0
|
|
2958
3210
|
&& activationReceipt.decision_id === decisionId
|
|
2959
|
-
&& activationReceipt.agent_id ===
|
|
3211
|
+
&& activationReceipt.agent_id === activationAgentId
|
|
2960
3212
|
&& activationReceipt.outcome_success === true
|
|
2961
3213
|
&& isCanonicalTimestamp(activationReceipt.outcome_recorded_at)
|
|
2962
3214
|
&& activationReceipt.server_confirmed === true
|
|
@@ -2975,7 +3227,7 @@ async function runSelfTest(options) {
|
|
|
2975
3227
|
event_id: `activation-${activationConfigFingerprint.slice(0, 32)}`,
|
|
2976
3228
|
event_type: 'activation_profile_registered',
|
|
2977
3229
|
harness: options.activation.harness,
|
|
2978
|
-
agent_id:
|
|
3230
|
+
agent_id: activationAgentId,
|
|
2979
3231
|
session_id: headers['x-marrow-session-id'],
|
|
2980
3232
|
adapter_version: activationAdapterVersion,
|
|
2981
3233
|
capability_level: activationCapabilityLevel,
|
|
@@ -3022,6 +3274,8 @@ async function runSelfTest(options) {
|
|
|
3022
3274
|
skipped: false,
|
|
3023
3275
|
mcp_tool_profile: toolProfile,
|
|
3024
3276
|
decision_id: decisionId,
|
|
3277
|
+
// Only committed:true is trusted closure; anything else is reported as such.
|
|
3278
|
+
decision_committed: commit?.committed === true,
|
|
3025
3279
|
active: Boolean(status.enabled ?? status.ok),
|
|
3026
3280
|
health: status.health || null,
|
|
3027
3281
|
last_event_at: status.last_event_at || null,
|
|
@@ -3031,7 +3285,13 @@ async function runSelfTest(options) {
|
|
|
3031
3285
|
runtime_active: runtimeGateVerified(runtime),
|
|
3032
3286
|
runtime_exact_next_action: runtime.exact_next_action || null,
|
|
3033
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,
|
|
3034
3292
|
activation_verified: activationVerified,
|
|
3293
|
+
activation_identity_unresolved: activationIdentityUnresolved,
|
|
3294
|
+
activation_exact_fix: activationIdentityUnresolved ? ACTIVATION_UNBOUND_KEY_FIX : null,
|
|
3035
3295
|
activation_scope: options.activation ? 'server_self_test_only' : null,
|
|
3036
3296
|
coverage_verified: false,
|
|
3037
3297
|
passive_live: false,
|
|
@@ -3185,112 +3445,118 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
|
|
|
3185
3445
|
};
|
|
3186
3446
|
}
|
|
3187
3447
|
|
|
3188
|
-
function printReport(report) {
|
|
3189
|
-
process.stdout.write(
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
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`);
|
|
3193
3454
|
|
|
3194
3455
|
if (report.activation?.requested) {
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
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');
|
|
3207
3469
|
for (const [key, value] of Object.entries(report.detected)) {
|
|
3208
|
-
|
|
3470
|
+
out(`- ${key}: ${value ? 'yes' : 'no'}\n`);
|
|
3209
3471
|
}
|
|
3210
3472
|
|
|
3211
|
-
|
|
3473
|
+
out('\nPlanned changes:\n');
|
|
3212
3474
|
for (const change of report.changes) {
|
|
3213
3475
|
const marker = change.automatic_repair_suppressed
|
|
3214
3476
|
? 'preserved unverified-ahead surface; repair suppressed'
|
|
3215
3477
|
: change.hook_conflict ? 'preserved unmanaged owner file; review required'
|
|
3216
3478
|
: change.applied ? 'wrote' : change.changed ? 'would write' : 'unchanged';
|
|
3217
|
-
|
|
3479
|
+
out(`- ${marker}: ${change.label} (${change.path})\n`);
|
|
3218
3480
|
if (change.automatic_repair_suppressed && change.exact_fix) {
|
|
3219
|
-
|
|
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`);
|
|
3220
3486
|
}
|
|
3221
|
-
if (change.hook_conflict && change.exact_fix) process.stdout.write(` exact fix: ${change.exact_fix}\n`);
|
|
3222
3487
|
}
|
|
3223
3488
|
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
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`);
|
|
3228
3493
|
if (!report.loop_guard_observed) {
|
|
3229
|
-
|
|
3494
|
+
out('- next: restart the host and complete its hook trust/review step before relying on live enforcement\n');
|
|
3230
3495
|
}
|
|
3231
3496
|
|
|
3232
|
-
|
|
3497
|
+
out('\nSelf-test:\n');
|
|
3233
3498
|
const toolProfile = report.selfTest.mcp_tool_profile || report.toolProfile;
|
|
3234
3499
|
if (toolProfile) {
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
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`);
|
|
3241
3506
|
const projection = toolProfile.backend_entitlement_projection;
|
|
3242
3507
|
const availability = projection?.primary_tool_availability;
|
|
3243
3508
|
if (projection?.evidence_state === 'available' && availability?.entitlement_evidence?.state === 'available') {
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
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`);
|
|
3247
3512
|
} else {
|
|
3248
|
-
|
|
3513
|
+
out(`- backend-projected entitlements: unavailable (source: ${projection?.source || 'backend_projection_not_provided'}; non-authorizing)\n`);
|
|
3249
3514
|
}
|
|
3250
3515
|
}
|
|
3251
3516
|
if (report.selfTest.skipped) {
|
|
3252
|
-
|
|
3253
|
-
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`);
|
|
3254
3519
|
} else {
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
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`);
|
|
3261
3527
|
const update = report.selfTest.client_update;
|
|
3262
3528
|
const notification = update?.notification_state || update?.notification;
|
|
3263
3529
|
if (update && (update.update_available === true || update.version_status === 'unknown' || notification === 'unknown' || notification === 'version_unknown' || notification === 'security_required')) {
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
if (update.owner_notice)
|
|
3271
|
-
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`);
|
|
3272
3538
|
if (update.update_command || update.exact_update_command || update.auto_update_command) {
|
|
3273
|
-
|
|
3539
|
+
out(`- update: ${update.auto_update_command || update.update_command || update.exact_update_command}\n`);
|
|
3274
3540
|
}
|
|
3275
|
-
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`);
|
|
3276
3542
|
}
|
|
3277
3543
|
if (report.selfTest.first_value_signal) {
|
|
3278
|
-
|
|
3544
|
+
out('\nFirst value:\n');
|
|
3279
3545
|
const valueMoment = report.selfTest.install_value_moment;
|
|
3280
3546
|
if (valueMoment) {
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
for (const proof of valueMoment.proof)
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
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`);
|
|
3287
3553
|
} else {
|
|
3288
|
-
|
|
3289
|
-
|
|
3554
|
+
out(`- ${report.selfTest.first_value_signal.headline}\n`);
|
|
3555
|
+
out(`- First useful lesson: ${report.selfTest.first_value_signal.first_lesson}\n`);
|
|
3290
3556
|
if (report.selfTest.first_value_signal.value_proof.length) {
|
|
3291
|
-
|
|
3557
|
+
out(`- Proof: ${report.selfTest.first_value_signal.value_proof.join('; ')}\n`);
|
|
3292
3558
|
}
|
|
3293
|
-
|
|
3559
|
+
out(`- Next: ${report.selfTest.first_value_signal.next_action}\n`);
|
|
3294
3560
|
}
|
|
3295
3561
|
}
|
|
3296
3562
|
if (report.selfTest.token_value_proof) {
|
|
@@ -3298,67 +3564,77 @@ function printReport(report) {
|
|
|
3298
3564
|
const observed = proof.observed || {};
|
|
3299
3565
|
const savings = proof.savings || {};
|
|
3300
3566
|
const tokens = observed.tokens || {};
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
if (savings.confidence)
|
|
3307
|
-
if (proof.proof_line)
|
|
3308
|
-
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`);
|
|
3309
3575
|
}
|
|
3310
3576
|
}
|
|
3311
3577
|
|
|
3312
3578
|
if (report.harnessReload && report.writeMode !== 'doctor') {
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
if (report.harnessReload.required && report.harnessReload.instruction)
|
|
3317
|
-
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`);
|
|
3318
3584
|
}
|
|
3319
3585
|
if (report.firstCapture) {
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
if (report.firstCapture.command)
|
|
3324
|
-
|
|
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`);
|
|
3325
3591
|
}
|
|
3326
3592
|
|
|
3327
3593
|
if (report.remediation) {
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
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`);
|
|
3333
3599
|
}
|
|
3334
3600
|
|
|
3335
|
-
if (report.
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
process.stdout.write(`- repairable: ${npm.repairable ? 'yes' : 'no'}\n`);
|
|
3341
|
-
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`);
|
|
3342
3606
|
}
|
|
3343
3607
|
|
|
3344
|
-
if (report.
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
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
|
+
}
|
|
3348
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`);
|
|
3349
3629
|
}
|
|
3350
3630
|
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
}
|
|
3357
|
-
|
|
3358
|
-
process.stdout.write('\nAutomatic controller:\n');
|
|
3359
|
-
process.stdout.write(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
|
|
3360
|
-
if (report.controller?.started_at) process.stdout.write(`- started: ${report.controller.started_at}\n`);
|
|
3361
|
-
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`);
|
|
3362
3638
|
|
|
3363
3639
|
if (report.writeMode === 'doctor') {
|
|
3364
3640
|
const liveHere = !report.harnessReload?.required
|
|
@@ -3369,49 +3645,113 @@ function printReport(report) {
|
|
|
3369
3645
|
: report.doctor.active
|
|
3370
3646
|
? 'server confirmed, restart required'
|
|
3371
3647
|
: 'no';
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
if (report.doctor.envHints.length)
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
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`);
|
|
3380
3656
|
if (report.doctor.mcpProcesses?.available) {
|
|
3381
3657
|
const processes = report.doctor.mcpProcesses;
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
if (processes.ahead_unverified_versions.length)
|
|
3385
|
-
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');
|
|
3386
3662
|
}
|
|
3387
3663
|
if (report.doctor.mcpConfigurations) {
|
|
3388
3664
|
const configurations = report.doctor.mcpConfigurations;
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
if (configurations.ahead_unverified_versions.length)
|
|
3392
|
-
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');
|
|
3393
3669
|
}
|
|
3394
|
-
if (report.doctor.recommendedFix)
|
|
3670
|
+
if (report.doctor.recommendedFix) out(`- recommended fix: ${report.doctor.recommendedFix}\n`);
|
|
3395
3671
|
if (report.doctor.mcp_update_required) {
|
|
3396
|
-
|
|
3397
|
-
|
|
3672
|
+
out(`- restart once after update: ${report.doctor.restart_instruction}\n`);
|
|
3673
|
+
out(`- verify after restart: ${report.doctor.verification_command}\n`);
|
|
3398
3674
|
}
|
|
3399
|
-
|
|
3675
|
+
out(`- live health: ${report.doctor.healthCommand}\n`);
|
|
3400
3676
|
}
|
|
3401
3677
|
|
|
3402
3678
|
if (report.writeMode === 'dry-run') {
|
|
3403
|
-
|
|
3679
|
+
out('\nRun with --yes to write these changes.\n');
|
|
3404
3680
|
}
|
|
3405
3681
|
|
|
3406
3682
|
if (report.warnings.length > 0) {
|
|
3407
|
-
|
|
3683
|
+
out('\nWarnings:\n');
|
|
3408
3684
|
for (const warning of report.warnings) {
|
|
3409
|
-
|
|
3685
|
+
out(`- ${warning}\n`);
|
|
3410
3686
|
}
|
|
3411
3687
|
}
|
|
3412
3688
|
}
|
|
3413
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
|
+
|
|
3414
3751
|
async function install(options) {
|
|
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.
|
|
3754
|
+
const configuredAgentId = String(options.agentId || '').trim();
|
|
3415
3755
|
if (options.activate && (options.yes !== true || options.dryRun || options.doctor)) {
|
|
3416
3756
|
throw new Error('activate requires write mode (--yes) because hooks must be installed during this run');
|
|
3417
3757
|
}
|
|
@@ -3432,9 +3772,11 @@ async function install(options) {
|
|
|
3432
3772
|
HOME: options.home || options.cwd,
|
|
3433
3773
|
USERPROFILE: options.home || options.cwd,
|
|
3434
3774
|
});
|
|
3775
|
+
if (options.repair || options.update) assertUpdateTargetsManagedRoot(detection);
|
|
3435
3776
|
const client = detectedClient(detection);
|
|
3436
3777
|
options.client = client;
|
|
3437
|
-
options.agentId =
|
|
3778
|
+
options.agentId = configuredAgentId;
|
|
3779
|
+
const controllerIdentityAgentId = localControllerAgentId(detection.root, client, configuredAgentId);
|
|
3438
3780
|
const observedMcpProcesses = inspectMcpProcesses({ commands: options.processCommands });
|
|
3439
3781
|
const observedMcpConfigurations = inspectMcpConfigurations(detection, { paths: options.mcpConfigPaths });
|
|
3440
3782
|
const registryMetadata = await readMcpRegistryMetadata(options);
|
|
@@ -3476,7 +3818,7 @@ async function install(options) {
|
|
|
3476
3818
|
};
|
|
3477
3819
|
options.activation = options.activate ? {
|
|
3478
3820
|
harness: client,
|
|
3479
|
-
agent_id:
|
|
3821
|
+
agent_id: configuredAgentId || null,
|
|
3480
3822
|
install_surface: plan.mode,
|
|
3481
3823
|
mode: options.governanceMode || 'passive',
|
|
3482
3824
|
hooks_installed: changes
|
|
@@ -3497,12 +3839,7 @@ async function install(options) {
|
|
|
3497
3839
|
intervention_verified: false,
|
|
3498
3840
|
closure_verified: false,
|
|
3499
3841
|
} : null;
|
|
3500
|
-
const configInspection = inspectNpmTokenConfig();
|
|
3501
3842
|
const sdkDependency = inspectSdkDependency(detection);
|
|
3502
|
-
const configDiagnostics = configInspection.safe;
|
|
3503
|
-
const configRepairs = options.repair && options.yes && !options.dryRun && !options.doctor
|
|
3504
|
-
? repairConfigDiagnostics(configDiagnostics)
|
|
3505
|
-
: [];
|
|
3506
3843
|
const envHints = options.apiKey ? [] : findLikelyEnvFiles(detection);
|
|
3507
3844
|
const mcpProcesses = inspectMcpProcesses({
|
|
3508
3845
|
commands: options.processCommands,
|
|
@@ -3552,9 +3889,12 @@ async function install(options) {
|
|
|
3552
3889
|
'',
|
|
3553
3890
|
registryVerificationAction,
|
|
3554
3891
|
);
|
|
3555
|
-
|
|
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) {
|
|
3556
3895
|
throw new Error('Marrow activation failed: server confirmation was not returned');
|
|
3557
3896
|
}
|
|
3897
|
+
const hermes = hermesWiringReport(detection, changes, options, plan.mode);
|
|
3558
3898
|
const plannedHarnessReload = harnessReloadPlan(detection, options.doctor ? [] : changes);
|
|
3559
3899
|
const updateNeedsRestart = Boolean(options.update
|
|
3560
3900
|
&& !automaticMcpRepairSuppressed
|
|
@@ -3599,12 +3939,12 @@ async function install(options) {
|
|
|
3599
3939
|
reload_required: true,
|
|
3600
3940
|
};
|
|
3601
3941
|
}
|
|
3602
|
-
const changedConfig = changes.some((change) => change.applied)
|
|
3942
|
+
const changedConfig = changes.some((change) => change.applied);
|
|
3603
3943
|
const selfTestPassed = Boolean(!selfTest.skipped && selfTest.active && !selfTest.error);
|
|
3604
3944
|
const controllerPlatform = options.controllerPlatform || process.platform;
|
|
3605
3945
|
let controller = await controllerStatus({
|
|
3606
3946
|
root: detection.root,
|
|
3607
|
-
|
|
3947
|
+
identityAgentId: controllerIdentityAgentId,
|
|
3608
3948
|
platform: controllerPlatform,
|
|
3609
3949
|
});
|
|
3610
3950
|
const shouldEnsureController = options.controller !== false
|
|
@@ -3617,10 +3957,11 @@ async function install(options) {
|
|
|
3617
3957
|
&& (options.yes || options.activate || options.repair);
|
|
3618
3958
|
if (shouldEnsureController) {
|
|
3619
3959
|
try {
|
|
3620
|
-
controller = await
|
|
3960
|
+
controller = await ensureCurrentGovernanceController({
|
|
3621
3961
|
apiKey: options.apiKey,
|
|
3622
3962
|
baseUrl: options.baseUrl,
|
|
3623
|
-
agentId:
|
|
3963
|
+
agentId: configuredAgentId,
|
|
3964
|
+
identityAgentId: controllerIdentityAgentId,
|
|
3624
3965
|
client,
|
|
3625
3966
|
root: detection.root,
|
|
3626
3967
|
mode: plan.mode,
|
|
@@ -3639,6 +3980,19 @@ async function install(options) {
|
|
|
3639
3980
|
if (options.activate) throw new Error(`Marrow activation failed: local controller did not start: ${message}`);
|
|
3640
3981
|
}
|
|
3641
3982
|
}
|
|
3983
|
+
// Only a controller that is simply not running is expected while the owner has disabled
|
|
3984
|
+
// local control. Unsafe state, an unverified or unresponsive process, and unsupported
|
|
3985
|
+
// platforms keep their exact fix.
|
|
3986
|
+
if (localControl.state === 'disabled'
|
|
3987
|
+
&& controllerSupportedPlatform(controllerPlatform)
|
|
3988
|
+
&& ['stopped', 'stale'].includes(controller.state)) {
|
|
3989
|
+
controller = {
|
|
3990
|
+
...controller,
|
|
3991
|
+
required: false,
|
|
3992
|
+
exact_fix: null,
|
|
3993
|
+
reason: 'Local control is disabled by the owner, so the controller is intentionally not started. It starts again on the next install or update after npx @getmarrow/install control enable.',
|
|
3994
|
+
};
|
|
3995
|
+
}
|
|
3642
3996
|
const remediation = options.repair
|
|
3643
3997
|
? {
|
|
3644
3998
|
attempted: true,
|
|
@@ -3676,8 +4030,10 @@ async function install(options) {
|
|
|
3676
4030
|
},
|
|
3677
4031
|
activation: {
|
|
3678
4032
|
requested: options.activate,
|
|
3679
|
-
agent_id:
|
|
4033
|
+
agent_id: selfTest.agent_id || configuredAgentId || null,
|
|
4034
|
+
agent_id_source: configuredAgentId ? 'configured' : selfTest.agent_id_source || 'unresolved',
|
|
3680
4035
|
server_confirmed: Boolean(selfTest.activation_verified),
|
|
4036
|
+
...(selfTest.activation_identity_unresolved ? { exact_fix: selfTest.activation_exact_fix } : {}),
|
|
3681
4037
|
activation_scope: selfTest.activation_scope || null,
|
|
3682
4038
|
coverage_verified: false,
|
|
3683
4039
|
passive_live: false,
|
|
@@ -3691,7 +4047,8 @@ async function install(options) {
|
|
|
3691
4047
|
loop_guard_observed: false,
|
|
3692
4048
|
loop_guard: loopGuardSelfTest,
|
|
3693
4049
|
harnessReload,
|
|
3694
|
-
firstCapture: firstCapturePath(detection
|
|
4050
|
+
firstCapture: firstCapturePath(detection),
|
|
4051
|
+
hermes,
|
|
3695
4052
|
changes,
|
|
3696
4053
|
doctor: {
|
|
3697
4054
|
active: Boolean(!selfTest.skipped && selfTest.active),
|
|
@@ -3712,7 +4069,7 @@ async function install(options) {
|
|
|
3712
4069
|
loop_guard_observed: false,
|
|
3713
4070
|
recommendedFix: registryVerificationAction
|
|
3714
4071
|
|| (updateAwaitingRestart ? INSTALLER_RESTART_INSTRUCTION : null)
|
|
3715
|
-
|| mcpProcesses.exact_fix || mcpConfigurations.exact_fix ||
|
|
4072
|
+
|| mcpProcesses.exact_fix || mcpConfigurations.exact_fix || (!options.apiKey
|
|
3716
4073
|
? envHints.length
|
|
3717
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.`
|
|
3718
4075
|
: 'Set MARROW_API_KEY, then run npx @getmarrow/install --repair.'
|
|
@@ -3720,8 +4077,6 @@ async function install(options) {
|
|
|
3720
4077
|
healthCommand: 'npx -y --package=@getmarrow/mcp@latest marrow-mcp ping',
|
|
3721
4078
|
},
|
|
3722
4079
|
remediation,
|
|
3723
|
-
configDiagnostics,
|
|
3724
|
-
configRepairs,
|
|
3725
4080
|
sdkDependency,
|
|
3726
4081
|
controller,
|
|
3727
4082
|
local_control: localControl,
|
|
@@ -3744,18 +4099,150 @@ async function install(options) {
|
|
|
3744
4099
|
};
|
|
3745
4100
|
}
|
|
3746
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
|
+
|
|
3747
4187
|
async function runCli(argv) {
|
|
3748
4188
|
const options = parseArgs(argv);
|
|
3749
4189
|
if (options.help) {
|
|
3750
4190
|
process.stdout.write(usage());
|
|
3751
4191
|
return;
|
|
3752
4192
|
}
|
|
3753
|
-
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
|
+
}
|
|
3754
4224
|
if (options.json) {
|
|
3755
4225
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
3756
|
-
|
|
4226
|
+
return;
|
|
4227
|
+
}
|
|
4228
|
+
if (!oneCommand || options.verbose) {
|
|
3757
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;
|
|
3758
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;
|
|
3759
4246
|
}
|
|
3760
4247
|
|
|
3761
4248
|
module.exports = {
|
|
@@ -3768,13 +4255,15 @@ module.exports = {
|
|
|
3768
4255
|
runLoopGuardSelfTest,
|
|
3769
4256
|
runCli,
|
|
3770
4257
|
passiveRuntimeSource,
|
|
3771
|
-
inspectNpmTokenConfig,
|
|
3772
4258
|
inspectSdkDependency,
|
|
3773
4259
|
inspectMcpProcesses,
|
|
3774
4260
|
inspectMcpConfigurations,
|
|
3775
4261
|
buildInstallValueMoment,
|
|
3776
4262
|
buildTokenValueProof,
|
|
3777
4263
|
stableAgentId,
|
|
4264
|
+
detectedClient,
|
|
4265
|
+
localControllerAgentId,
|
|
4266
|
+
identityAllowlist,
|
|
3778
4267
|
activationProfile,
|
|
3779
4268
|
claudeNativeHookFingerprint,
|
|
3780
4269
|
codexNativeHookFingerprint,
|
|
@@ -3792,6 +4281,7 @@ module.exports = {
|
|
|
3792
4281
|
CURSOR_NATIVE_HOOK_MATCHER,
|
|
3793
4282
|
GEMINI_NATIVE_HOOK_MATCHER,
|
|
3794
4283
|
printReport,
|
|
4284
|
+
installSummaryLines,
|
|
3795
4285
|
buildMcpToolProfileReport,
|
|
3796
4286
|
resolveMcpTargetVersion,
|
|
3797
4287
|
resolveToolProfile,
|