@getmarrow/install 0.1.65 → 0.1.67

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