@getmarrow/install 0.1.64 → 0.1.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,6 +87,8 @@ npx -y --package=@getmarrow/mcp@latest marrow-mcp ping
87
87
 
88
88
  `update` resolves official npm metadata once, selects one exact verified MCP target, and synchronizes every Marrow-managed surface in the detected owning workspace while retaining unrelated user hooks and configuration. Restart the detected owning harnesses once after it completes; running processes do not change before that restart. Run the doctor verification once after restart. Do not run separate `marrow-mcp setup` and restart cycles for the same detected workspace.
89
89
 
90
+ `update` and `--repair` only refresh an existing install. Run from a directory with no Marrow-managed files while your home directory is managed, they stop without writing and print the exact `update --cwd <home>` command; to add Marrow to that project, run the install command there instead. Managed JSON that differs only in key order or formatting, for example after a harness re-saves its settings, counts as present and is not rewritten.
91
+
90
92
  `activate` remains available for initial activation. After explicit activation, the local controller can restore drifted Marrow-managed hooks and configuration. Package upgrades, owner policy, credentials, explicitly disabled hooks, and unrelated local files remain explicit and subject to the operator's normal change policy.
91
93
 
92
94
  ## Automatic Controller
@@ -101,7 +103,24 @@ npx @getmarrow/install controller stop
101
103
 
102
104
  Persistent controller lifecycle is currently Linux-only. On macOS or Windows, activation still writes supported configuration and verifies one server-side install self-test without certifying that hooks continuously ran; run `npx @getmarrow/install sidecar` under an owner-managed service and pass `--no-controller`. The controller does not silently upgrade packages, change governance policy, rotate credentials, or modify unrelated project configuration.
103
105
 
104
- ## What's New in v0.1.64
106
+ The controller is not a boot service. After a host restart, or any exit that skips its shutdown handler, doctor reports it as `stale` until the next install, update, or `controller ensure`. While local control is disabled, the controller is not started, and doctor reports a stopped or stale controller as not required instead of recommending `controller ensure`; unsafe controller state or an unverified or unresponsive controller process keeps its exact fix.
107
+
108
+ ## What's New in v0.1.65
109
+
110
+ v0.1.65 pins MCP `3.9.97` (source `be607e1dffd4d9e6a3c40f01151e509c115fba3f`, packed integrity `sha512-wab1kvgec8WhDDrpkajauLu2QdADXudIxkic0BAgzV96FZVHY0ME45hrOyQmqfkI+OtKriYdgIFp90peWGh2mA==`) while keeping SDK `3.7.64` unchanged. Changes:
111
+
112
+ - `doctor` no longer reports hooks as missing after a harness re-saves its settings file.
113
+ - `update` refuses to write into an unrelated folder and only touches Marrow-managed roots.
114
+ - The install self-test handles pending decisions and never commits one as a success.
115
+ - Controller reporting is clearer; a disabled controller stays quiet while exact fixes for non-benign states are still shown.
116
+ - Enforcement heartbeats back off after repeated failures and retry once an hour.
117
+ - The agent id is never derived from the OS username.
118
+ - Permit verification sends `protocol_version`.
119
+ - The test suite is isolated from the real HOME and Marrow credentials.
120
+
121
+ Restart the owning harness, review hook trust and run `doctor --self-test` after updating. Package publication and registry integrity verification must precede claiming these candidate pins are publicly available.
122
+
123
+ ### Previous release: v0.1.64
105
124
 
106
125
  v0.1.64 pins MCP `3.9.96` (source `031c944936271fd6e8768ad2619ee0430b68e2c7`, packed integrity `sha512-kuBpuWaWAvusS+FbXXFw1fOEGja4T8ahj/vkmXTd/CWjz0t6teT6g9c1HfJhAK6KLzesZtcvUdZLn6pBErKP9Q==`) while keeping SDK `3.7.64` unchanged. The embedded MCP pin requires this installer patch to deliver the native pre-action hook permit fix: protected actions are no longer denied after an allowing runtime gate, and unprotected actions stop at the gate without creating a decision or permit. Policy decisions, proof requirements and fail-closed behavior are unchanged. `activate` and `doctor --self-test` now resend the identical self-test decision and commit with a stable `Idempotency-Key` after a transient 429/502/503/504 or a durable pending acknowledgement (at most three attempts, about one second apart), instead of failing with "self-test did not return decision_id"; client errors still fail immediately, and a final failure names the last state. Restart the owning harness, review hook trust and run `doctor --self-test` after updating. Package publication and registry integrity verification must precede claiming these candidate pins are publicly available.
107
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmarrow/install",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "description": "Universal installer and governed runner for Marrow agent fleets.",
5
5
  "bin": {
6
6
  "marrow-install": "bin/marrow-install.js"
@@ -12,7 +12,7 @@ const LIFECYCLE_LOCK_STALE_MS = 30_000;
12
12
 
13
13
  function controllerIdentity(options = {}) {
14
14
  const root = path.resolve(options.root || process.cwd());
15
- const agentId = String(options.agentId || process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || 'agent').trim() || 'agent';
15
+ const agentId = String(options.identityAgentId || options.agentId || process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || 'agent').trim() || 'agent';
16
16
  return crypto.createHash('sha256').update(`${root}\0${agentId}`).digest('hex').slice(0, 24);
17
17
  }
18
18
 
@@ -201,13 +201,17 @@ async function controllerStatus(options = {}) {
201
201
  const maintenance = body.maintenance && typeof body.maintenance === 'object'
202
202
  ? body.maintenance
203
203
  : null;
204
+ const heartbeat = body.heartbeat && typeof body.heartbeat === 'object'
205
+ ? body.heartbeat
206
+ : null;
204
207
  return {
205
208
  active: true,
206
209
  state: 'active',
207
210
  started_at: state.started_at,
208
211
  instance_id: state.instance_id,
209
212
  maintenance,
210
- exact_fix: maintenance?.exact_fix || null,
213
+ heartbeat,
214
+ exact_fix: maintenance?.exact_fix || heartbeat?.exact_fix || null,
211
215
  };
212
216
  } catch {
213
217
  return {
@@ -233,8 +237,10 @@ function cleanControllerEnv(options) {
233
237
  }
234
238
  env.MARROW_API_KEY = options.apiKey;
235
239
  env.MARROW_BASE_URL = options.baseUrl;
236
- env.MARROW_FLEET_AGENT_ID = options.agentId;
237
- env.MARROW_AGENT_ID = options.agentId;
240
+ if (options.agentId) {
241
+ env.MARROW_FLEET_AGENT_ID = options.agentId;
242
+ env.MARROW_AGENT_ID = options.agentId;
243
+ }
238
244
  env.MARROW_CLIENT = options.client;
239
245
  env.MARROW_GOVERN_PROFILE = options.profile;
240
246
  env.MARROW_GOVERN_POLICY = options.policy;
@@ -370,7 +376,7 @@ async function startGovernanceController(options) {
370
376
  const args = [
371
377
  binPath,
372
378
  'sidecar',
373
- '--agent', options.agentId,
379
+ ...(options.agentId ? ['--agent', options.agentId] : []),
374
380
  '--client', options.client,
375
381
  '--profile', options.profile,
376
382
  '--policy', options.policy,
@@ -1,5 +1,8 @@
1
1
  const crypto = require('node:crypto');
2
2
 
3
+ // A background heartbeat must never hang on a stalled API response.
4
+ const HEARTBEAT_REQUEST_TIMEOUT_MS = 15_000;
5
+
3
6
  function sha256(value) {
4
7
  return crypto.createHash('sha256').update(String(value || '')).digest('hex');
5
8
  }
@@ -17,11 +20,22 @@ function actionBinding(input) {
17
20
  };
18
21
  }
19
22
 
20
- async function enforcementRequest(requestJson, options, operation, input = {}) {
21
- return requestJson(options, 'POST', '/v1/agent/enforcement', {
22
- operation,
23
- ...input,
24
- });
23
+ // The server resolves the key-bound or seat agent when no agent id is configured.
24
+ function configuredAgentId(options) {
25
+ return String(options.agentId || '').trim() || undefined;
26
+ }
27
+
28
+ // Verify and close must declare the protocol the permit was issued under (1 or 2).
29
+ function permitProtocolVersion(input) {
30
+ const version = input.protocolVersion ?? input.protocol_version;
31
+ return version === 1 || version === 2 ? version : 1;
32
+ }
33
+
34
+ async function enforcementRequest(requestJson, options, operation, input = {}, requestOptions = undefined) {
35
+ const body = { operation, ...input };
36
+ return requestOptions
37
+ ? requestJson(options, 'POST', '/v1/agent/enforcement', body, {}, requestOptions)
38
+ : requestJson(options, 'POST', '/v1/agent/enforcement', body);
25
39
  }
26
40
 
27
41
  async function issueActionPermit(requestJson, options, input) {
@@ -29,7 +43,7 @@ async function issueActionPermit(requestJson, options, input) {
29
43
  return enforcementRequest(requestJson, options, 'issue', {
30
44
  ...binding,
31
45
  session_id: options.sessionId,
32
- agent_id: options.agentId,
46
+ agent_id: configuredAgentId(options),
33
47
  harness: options.client,
34
48
  policy_mode: options.policy,
35
49
  decision_id: input.decisionId || null,
@@ -46,8 +60,9 @@ async function verifyActionPermit(requestJson, options, input) {
46
60
  ...binding,
47
61
  surfaces: Array.isArray(input.surfaces) ? input.surfaces : [],
48
62
  permit: input.permit,
63
+ protocol_version: permitProtocolVersion(input),
49
64
  session_id: options.sessionId,
50
- agent_id: options.agentId,
65
+ agent_id: configuredAgentId(options),
51
66
  harness: options.client,
52
67
  });
53
68
  }
@@ -58,22 +73,23 @@ async function closeActionPermit(requestJson, options, input) {
58
73
  permit_id: input.permitId || null,
59
74
  decision_id: input.decisionId || null,
60
75
  session_id: options.sessionId,
61
- agent_id: options.agentId,
76
+ agent_id: configuredAgentId(options),
62
77
  success: Boolean(input.success),
63
78
  evidence: input.evidence || {},
79
+ protocol_version: permitProtocolVersion(input),
64
80
  });
65
81
  }
66
82
 
67
83
  async function recordEnforcementHeartbeat(requestJson, options, input = {}) {
68
84
  return enforcementRequest(requestJson, options, 'heartbeat', {
69
85
  session_id: options.sessionId,
70
- agent_id: options.agentId,
86
+ agent_id: configuredAgentId(options),
71
87
  harness: options.client,
72
88
  sidecar_instance_id: input.sidecarInstanceId || null,
73
89
  config_fingerprint: input.configFingerprint || null,
74
90
  expected_hooks: input.expectedHooks || ['pre_action', 'action_result', 'outcome_closure'],
75
91
  observed_hooks: input.observedHooks || ['pre_action'],
76
- });
92
+ }, { timeoutMs: HEARTBEAT_REQUEST_TIMEOUT_MS });
77
93
  }
78
94
 
79
95
  async function readEnforcementCoverage(requestJson, options) {
@@ -83,8 +99,10 @@ async function readEnforcementCoverage(requestJson, options) {
83
99
  }
84
100
 
85
101
  module.exports = {
102
+ HEARTBEAT_REQUEST_TIMEOUT_MS,
86
103
  actionBinding,
87
104
  sha256,
105
+ permitProtocolVersion,
88
106
  enforcementRequest,
89
107
  issueActionPermit,
90
108
  verifyActionPermit,
@@ -5,6 +5,87 @@ const os = require('node:os');
5
5
  const path = require('node:path');
6
6
 
7
7
  const MAX_BODY_BYTES = 64 * 1024;
8
+ const HEARTBEAT_INTERVAL_MS = 30_000;
9
+ const HEARTBEAT_MAX_BACKOFF_MS = 30 * 60_000;
10
+ const HEARTBEAT_CIRCUIT_THRESHOLD = 5;
11
+ const HEARTBEAT_PROBE_MS = 60 * 60_000;
12
+ const CONTROLLER_RESTART = 'npx @getmarrow/install controller stop && npx @getmarrow/install controller ensure';
13
+
14
+ function heartbeatFailure(error) {
15
+ const status = Number.isInteger(error?.status) ? error.status : null;
16
+ const rawCode = error?.details?.code || error?.code;
17
+ const code = typeof rawCode === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/.test(rawCode) ? rawCode : null;
18
+ return {
19
+ status,
20
+ code,
21
+ // A 4xx other than timeout or rate limit repeats until the request or its identity changes.
22
+ permanent: status !== null && status >= 400 && status < 500 && status !== 408 && status !== 429,
23
+ signature: `${status ?? 'network'}:${code ?? ''}`,
24
+ };
25
+ }
26
+
27
+ function heartbeatExactFix({ status, code }) {
28
+ const failure = `HTTP ${status}${code ? ` ${code}` : ''}`;
29
+ if (status === 404) {
30
+ return `Marrow has no registered agent for this controller (${failure}). Create the agent or bind this API key to a registered agent, set MARROW_FLEET_AGENT_ID to its id if the key is not bound, then run ${CONTROLLER_RESTART} to retry now.`;
31
+ }
32
+ if (status === 401 || status === 403) {
33
+ return `Marrow rejected this controller's API key or agent scope (${failure}). Use an active key bound to the registered agent, then run ${CONTROLLER_RESTART} to retry now.`;
34
+ }
35
+ return `Marrow rejected the controller heartbeat (${failure}). Run npx -y @getmarrow/install@latest update, then ${CONTROLLER_RESTART} to retry now.`;
36
+ }
37
+
38
+ // Heartbeats back off exponentially with jitter after any failure. Repeated identical 4xx
39
+ // responses open the circuit: heartbeats then drop to one jittered probe about every hour,
40
+ // so a fleet-wide transient 401/403/404 recovers without restarts. 5xx, 429, and network
41
+ // failures keep retrying at the capped backoff. A notice is returned only when the circuit
42
+ // opens, when its fix changes, and when heartbeats recover.
43
+ function createHeartbeatPolicy({
44
+ intervalMs = HEARTBEAT_INTERVAL_MS,
45
+ maxBackoffMs = HEARTBEAT_MAX_BACKOFF_MS,
46
+ circuitThreshold = HEARTBEAT_CIRCUIT_THRESHOLD,
47
+ probeMs = HEARTBEAT_PROBE_MS,
48
+ random = Math.random,
49
+ } = {}) {
50
+ let consecutive = 0;
51
+ let identical = 0;
52
+ let last = null;
53
+ let openFix = null;
54
+ return {
55
+ get open() { return openFix !== null; },
56
+ success() {
57
+ const recovered = openFix !== null;
58
+ consecutive = 0;
59
+ identical = 0;
60
+ last = null;
61
+ openFix = null;
62
+ return {
63
+ delayMs: intervalMs,
64
+ state: { state: 'ok', failures: 0, exact_fix: null },
65
+ notice: recovered ? 'Marrow controller resumed enforcement heartbeats.' : null,
66
+ };
67
+ },
68
+ failure(error) {
69
+ const failure = heartbeatFailure(error);
70
+ consecutive += 1;
71
+ identical = last?.signature === failure.signature ? identical + 1 : 1;
72
+ last = failure;
73
+ const summary = { status: failure.status, code: failure.code, failures: identical };
74
+ if (openFix !== null || (failure.permanent && identical >= circuitThreshold)) {
75
+ const fix = failure.permanent ? heartbeatExactFix(failure) : openFix;
76
+ const notice = fix === openFix
77
+ ? null
78
+ : `Marrow controller paused enforcement heartbeats and retries about once an hour. ${fix}`;
79
+ openFix = fix;
80
+ const delayMs = Math.round(probeMs * (0.75 + random() * 0.5));
81
+ return { delayMs, state: { state: 'circuit_open', ...summary, retry_in_ms: delayMs, exact_fix: fix }, notice };
82
+ }
83
+ const ceiling = Math.min(maxBackoffMs, intervalMs * 2 ** consecutive);
84
+ const delayMs = Math.round(ceiling / 2 + random() * (ceiling / 2));
85
+ return { delayMs, state: { state: 'backing_off', ...summary, retry_in_ms: delayMs, exact_fix: null }, notice: null };
86
+ },
87
+ };
88
+ }
8
89
 
9
90
  function sidecarStateDir() {
10
91
  return process.env.MARROW_SIDECAR_STATE_DIR || path.join(os.homedir(), '.marrow', 'sidecar');
@@ -142,6 +223,7 @@ async function startGovernanceSidecar(options, handlers) {
142
223
  repaired: [],
143
224
  exact_fix: handlers.maintain ? null : 'Run npx @getmarrow/install --repair in the managed project.',
144
225
  };
226
+ let latestHeartbeat = { state: 'pending', failures: 0, exact_fix: null, checked_at: null };
145
227
 
146
228
  const server = http.createServer(async (req, res) => {
147
229
  try {
@@ -159,6 +241,7 @@ async function startGovernanceSidecar(options, handlers) {
159
241
  pid: process.pid,
160
242
  started_at: startedAt,
161
243
  maintenance: latestMaintenance,
244
+ heartbeat: latestHeartbeat,
162
245
  });
163
246
  }
164
247
  if (req.method === 'GET' && url.pathname === '/coverage') {
@@ -200,27 +283,54 @@ async function startGovernanceSidecar(options, handlers) {
200
283
  throw error;
201
284
  }
202
285
 
203
- const heartbeat = async () => {
204
- if (handlers.maintain) {
205
- try {
206
- latestMaintenance = await handlers.maintain();
207
- } catch {
208
- latestMaintenance = {
209
- state: 'attention_required',
210
- checked_at: new Date().toISOString(),
211
- repaired: [],
212
- exact_fix: 'Run npx @getmarrow/install --repair in the managed project.',
213
- };
214
- }
215
- }
286
+ const heartbeatPolicy = createHeartbeatPolicy({
287
+ intervalMs: options.heartbeatIntervalMs,
288
+ probeMs: options.heartbeatProbeMs,
289
+ random: options.heartbeatRandom,
290
+ });
291
+ let nextHeartbeatAt = 0;
292
+ // Maintenance and heartbeat have separate in-flight guards, so a hung heartbeat request
293
+ // never stalls drift repair.
294
+ let maintenanceRunning = false;
295
+ let heartbeatRunning = false;
296
+ const runMaintenance = async () => {
297
+ if (!handlers.maintain || maintenanceRunning) return;
298
+ maintenanceRunning = true;
216
299
  try {
217
- latestCoverage = await handlers.heartbeat({ sidecarInstanceId: instanceId });
300
+ latestMaintenance = await handlers.maintain();
218
301
  } catch {
219
- // Coverage will mark stale heartbeat; never weaken execution policy here.
302
+ latestMaintenance = {
303
+ state: 'attention_required',
304
+ checked_at: new Date().toISOString(),
305
+ repaired: [],
306
+ exact_fix: 'Run npx @getmarrow/install --repair in the managed project.',
307
+ };
308
+ } finally {
309
+ maintenanceRunning = false;
310
+ }
311
+ };
312
+ const runHeartbeat = async () => {
313
+ if (heartbeatRunning || Date.now() < nextHeartbeatAt) return;
314
+ heartbeatRunning = true;
315
+ try {
316
+ let result;
317
+ try {
318
+ latestCoverage = await handlers.heartbeat({ sidecarInstanceId: instanceId });
319
+ result = heartbeatPolicy.success();
320
+ } catch (error) {
321
+ // Coverage will mark a stale heartbeat; never weaken execution policy here.
322
+ result = heartbeatPolicy.failure(error);
323
+ }
324
+ latestHeartbeat = { ...result.state, checked_at: new Date().toISOString() };
325
+ nextHeartbeatAt = Date.now() + result.delayMs;
326
+ if (result.notice) process.stderr.write(`${result.notice}\n`);
327
+ } finally {
328
+ heartbeatRunning = false;
220
329
  }
221
330
  };
222
- await heartbeat();
223
- const timer = setInterval(heartbeat, 30_000);
331
+ const tick = () => Promise.all([runMaintenance(), runHeartbeat()]);
332
+ await tick();
333
+ const timer = setInterval(tick, options.heartbeatIntervalMs || HEARTBEAT_INTERVAL_MS);
224
334
  timer.unref();
225
335
 
226
336
  let closed = false;
@@ -239,4 +349,4 @@ async function startGovernanceSidecar(options, handlers) {
239
349
  return { server, instanceId, port: boundPort, stateFile, close };
240
350
  }
241
351
 
242
- module.exports = { startGovernanceSidecar, sidecarStateDir };
352
+ module.exports = { startGovernanceSidecar, sidecarStateDir, createHeartbeatPolicy };
@@ -352,7 +352,9 @@ function parseBaseOptions(argv, startIndex = 0) {
352
352
  const options = {
353
353
  apiKey: process.env.MARROW_API_KEY || process.env.MARROW_KEY || '',
354
354
  baseUrl: process.env.MARROW_BASE_URL || DEFAULT_BASE_URL,
355
- agentId: process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || os.userInfo().username || 'agent',
355
+ // A local username is never a registered agent. With no configured id, the server
356
+ // resolves the key-bound agent or the plan seat instead.
357
+ agentId: process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || '',
356
358
  sessionId: process.env.MARROW_SESSION_ID || '',
357
359
  profile: process.env.MARROW_GOVERN_PROFILE || 'default',
358
360
  policy: process.env.MARROW_GOVERN_POLICY || 'enforce',
@@ -481,7 +483,6 @@ function headers(options) {
481
483
  const h = {
482
484
  Authorization: `Bearer ${options.apiKey}`,
483
485
  'Content-Type': 'application/json',
484
- 'X-Marrow-Agent-Id': options.agentId,
485
486
  'X-Marrow-Session-Id': options.sessionId,
486
487
  'X-Marrow-Client': sourceClient(options.client),
487
488
  'X-Marrow-Package': '@getmarrow/install',
@@ -491,6 +492,7 @@ function headers(options) {
491
492
  'X-Marrow-MCP-Version': ADAPTER_PROVENANCE.mcp.version,
492
493
  'User-Agent': '@getmarrow/install governed-runner',
493
494
  };
495
+ if (options.agentId) h['X-Marrow-Agent-Id'] = options.agentId;
494
496
  return h;
495
497
  }
496
498
 
@@ -501,7 +503,7 @@ function sourceMeta(options, channel, extra = {}) {
501
503
  client,
502
504
  harness: client,
503
505
  runner: '@getmarrow/install',
504
- agent_id: options.agentId,
506
+ ...(options.agentId ? { agent_id: options.agentId } : {}),
505
507
  session_id: options.sessionId,
506
508
  profile: options.profile,
507
509
  governed: true,
@@ -514,12 +516,13 @@ function dataOf(json) {
514
516
  return json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : json;
515
517
  }
516
518
 
517
- async function requestJson(options, method, route, body, extraHeaders = {}) {
519
+ async function requestJson(options, method, route, body, extraHeaders = {}, { timeoutMs } = {}) {
518
520
  if (!options.apiKey) throw new Error('MARROW_API_KEY is required. Use --fail-open only for non-production local commands.');
519
521
  const response = await fetch(new URL(route, options.baseUrl.replace(/\/$/, '/')), {
520
522
  method,
521
523
  headers: { ...headers(options), ...extraHeaders },
522
524
  body: body === undefined ? undefined : JSON.stringify(body),
525
+ ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
523
526
  });
524
527
  const text = await response.text();
525
528
  let json = {};
@@ -613,7 +616,7 @@ async function recommendGovernanceMode(options, project = detectProjectSignals()
613
616
  environment: process.env.NODE_ENV || process.env.MARROW_GOVERN_PROFILE || options.profile,
614
617
  },
615
618
  agent: {
616
- id: options.agentId,
619
+ id: options.agentId || undefined,
617
620
  role: 'setup',
618
621
  },
619
622
  source_meta: sourceMeta(options, 'mode_recommend', { action }),
@@ -631,7 +634,7 @@ async function recordGovernanceModeSelection(options, state) {
631
634
  environment: process.env.NODE_ENV || process.env.MARROW_GOVERN_PROFILE || options.profile,
632
635
  },
633
636
  agent: {
634
- id: options.agentId,
637
+ id: options.agentId || undefined,
635
638
  role: 'setup',
636
639
  },
637
640
  selected_mode: selected,
@@ -842,6 +845,7 @@ async function runGoverned(parsed, execution = {}) {
842
845
  target,
843
846
  surfaces,
844
847
  permit: actionPermit.permit,
848
+ protocolVersion: actionPermit.protocol_version,
845
849
  });
846
850
  if (verified?.verified !== true) throw new Error('Marrow action permit verification failed.');
847
851
  permitVerified = true;
@@ -899,6 +903,7 @@ async function runGoverned(parsed, execution = {}) {
899
903
  permitClosed = await closeActionPermit(requestJson, options, {
900
904
  permit: actionPermit.permit,
901
905
  permitId: actionPermit.permit_id,
906
+ protocolVersion: actionPermit.protocol_version,
902
907
  decisionId,
903
908
  success,
904
909
  evidence: proof,
@@ -1017,7 +1022,9 @@ async function sidecarOnly(parsed) {
1017
1022
  }
1018
1023
 
1019
1024
  async function controllerOnly(parsed) {
1020
- const options = { ...parsed.options, root: process.cwd(), mode: 'auto' };
1025
+ // Keep the local controller identity stable; only the server-facing agent id changed.
1026
+ const identityAgentId = parsed.options.agentId || os.userInfo().username || 'agent';
1027
+ const options = { ...parsed.options, identityAgentId, root: process.cwd(), mode: 'auto' };
1021
1028
  let result;
1022
1029
  if (parsed.action === 'ensure') result = await ensureGovernanceController(options);
1023
1030
  else if (parsed.action === 'start') result = await startGovernanceController(options);
@@ -1441,7 +1448,7 @@ function fleetPanel(snapshot) {
1441
1448
  return [
1442
1449
  'Marrow Fleet Operator',
1443
1450
  '',
1444
- `Agent: ${snapshot.agent_id}`,
1451
+ `Agent: ${snapshot.agent_id || 'resolved by API key'}`,
1445
1452
  `Snapshot: ${snapshot.generated_at}`,
1446
1453
  '',
1447
1454
  `Live agents: ${snapshot.live_agents ?? 'unknown'}`,
@@ -1747,7 +1754,7 @@ async function integrationsOnly(parsed) {
1747
1754
  function governPanel(options) {
1748
1755
  const rows = detectHarnesses();
1749
1756
  const project = detectProjectSignals();
1750
- const agentId = displayText(options.agentId, 80);
1757
+ const agentId = displayText(options.agentId, 80) || 'resolved by API key';
1751
1758
  const profile = displayText(options.profile, 80);
1752
1759
  const policy = displayText(options.policy, 24);
1753
1760
  const lines = [
@@ -1770,7 +1777,7 @@ function governPanel(options) {
1770
1777
  : ' recommendation: export MARROW_API_KEY to get an account/fleet-backed mode recommendation.',
1771
1778
  '',
1772
1779
  'Recommended first commands:',
1773
- ` npx @getmarrow/install run --agent ${shellQuoteDisplay(options.agentId)} --profile production --policy enforce -- codex`,
1780
+ ` npx @getmarrow/install run ${options.agentId ? `--agent ${shellQuoteDisplay(options.agentId)} ` : ''}--profile production --policy enforce -- codex`,
1774
1781
  ` npx @getmarrow/install run --agent deploy-agent --type deploy --policy enforce -- wrangler deploy`,
1775
1782
  ` npx @getmarrow/install gate "deploy production worker after tests pass"`,
1776
1783
  '',
@@ -1817,7 +1824,7 @@ function commandForSelection(state, options) {
1817
1824
  }
1818
1825
  const command = harness.command === '<your-agent-command>' ? '<your-command>' : harness.command;
1819
1826
  const renderedCommand = command.split(/\s+/).filter(Boolean).map(shellQuote).join(' ');
1820
- return `MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent ${shellQuoteDisplay(options.agentId)} --profile ${shellQuoteDisplay(options.profile)} --policy ${shellQuoteDisplay(mode.policy)} -- ${renderedCommand}`;
1827
+ return `MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run ${options.agentId ? `--agent ${shellQuoteDisplay(options.agentId)} ` : ''}--profile ${shellQuoteDisplay(options.profile)} --policy ${shellQuoteDisplay(mode.policy)} -- ${renderedCommand}`;
1821
1828
  }
1822
1829
 
1823
1830
  function buildGovernState(options, cwd = process.cwd()) {
@@ -1910,7 +1917,7 @@ function renderGovernTui(state, options) {
1910
1917
  '| Passive agent governance for day-one use |',
1911
1918
  '+------------------------------------------------------------+',
1912
1919
  '',
1913
- `Agent: ${displayText(options.agentId, 36)} Profile: ${displayText(options.profile, 24)} API key: ${options.apiKey ? 'present' : 'missing'}`,
1920
+ `Agent: ${displayText(options.agentId, 36) || 'resolved by API key'} Profile: ${displayText(options.profile, 24)} API key: ${options.apiKey ? 'present' : 'missing'}`,
1914
1921
  `Project: ${displayText(state.project?.name || 'workspace', 36)} Signals: ${displayText((state.project?.signals || []).slice(0, 4).join(', ') || 'none', 52)}`,
1915
1922
  '',
1916
1923
  'Navigation: Up/Down move Left/Right change Enter select',
@@ -2036,7 +2043,7 @@ function renderFleetTui(state) {
2036
2043
  '| Live fleet health, proof debt, gates, and exact fixes |',
2037
2044
  '+------------------------------------------------------------+',
2038
2045
  '',
2039
- `Agent: ${displayText(snapshot.agent_id, 36)} Snapshot: ${displayText(snapshot.generated_at, 36)}`,
2046
+ `Agent: ${displayText(snapshot.agent_id, 36) || 'resolved by API key'} Snapshot: ${displayText(snapshot.generated_at, 36)}`,
2040
2047
  `API: ${displayText(snapshot.base_url, 60)}`,
2041
2048
  '',
2042
2049
  'Navigation: Up/Down move Left/Right select agent Enter inspect/print',
@@ -2419,6 +2426,7 @@ module.exports = {
2419
2426
  verifyPermitOnly,
2420
2427
  coverageOnly,
2421
2428
  sidecarOnly,
2429
+ requestJson,
2422
2430
  controllerOnly,
2423
2431
  actionBinding,
2424
2432
  gateOnly,
package/src/installer.js CHANGED
@@ -11,9 +11,9 @@ const { evidence: localControlEvidence } = require('./control-state');
11
11
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
12
12
  const MARROW_BLOCK_START = '<!-- marrow:passive-start -->';
13
13
  const MARROW_BLOCK_END = '<!-- marrow:passive-end -->';
14
- const MCP_ADAPTER_VERSION = '3.9.96';
15
- const MCP_ADAPTER_SOURCE_SHA = '031c944936271fd6e8768ad2619ee0430b68e2c7';
16
- const MCP_ADAPTER_INTEGRITY = 'sha512-kuBpuWaWAvusS+FbXXFw1fOEGja4T8ahj/vkmXTd/CWjz0t6teT6g9c1HfJhAK6KLzesZtcvUdZLn6pBErKP9Q==';
14
+ const MCP_ADAPTER_VERSION = '3.9.97';
15
+ const MCP_ADAPTER_SOURCE_SHA = 'be607e1dffd4d9e6a3c40f01151e509c115fba3f';
16
+ const MCP_ADAPTER_INTEGRITY = 'sha512-wab1kvgec8WhDDrpkajauLu2QdADXudIxkic0BAgzV96FZVHY0ME45hrOyQmqfkI+OtKriYdgIFp90peWGh2mA==';
17
17
  const SDK_ADAPTER_VERSION = '3.7.64';
18
18
  const SDK_ADAPTER_INTEGRITY = 'sha512-8qJj/8ouHEz1NnZkmujtFxUm/fWldqR/rHv62/sqabaxT0H90xCCHxodLOkV0SVxDscAtnahioZakqkeGNUwyA==';
19
19
  const SDK_ADAPTER_TARBALL = `https://registry.npmjs.org/@getmarrow/sdk/-/sdk-${SDK_ADAPTER_VERSION}.tgz`;
@@ -968,6 +968,54 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
968
968
  };
969
969
  }
970
970
 
971
+ const MARROW_MANAGED_ROOT_FILES = Object.freeze([
972
+ 'AGENTS.md',
973
+ 'CLAUDE.md',
974
+ '.mcp.json',
975
+ path.join('.claude', 'settings.json'),
976
+ path.join('.codex', 'hooks.json'),
977
+ path.join('.cursor', 'hooks.json'),
978
+ path.join('.cursor', 'mcp.json'),
979
+ path.join('.cursor', 'rules', 'marrow.mdc'),
980
+ path.join('.windsurf', 'hooks.json'),
981
+ path.join('.gemini', 'settings.json'),
982
+ path.join('.clinerules', 'hooks', 'PreToolUse'),
983
+ path.join('.clinerules', 'hooks', 'PostToolUse'),
984
+ path.join('.clinerules', 'hooks', 'TaskCancel'),
985
+ path.join('.marrow', 'passive-runtime.mjs'),
986
+ ]);
987
+ const MARROW_MANAGED_TEXT_RE = /@getmarrow\/mcp|marrow-mcp|<!-- marrow:passive-start -->/;
988
+
989
+ function marrowManagedRootFiles(root) {
990
+ return MARROW_MANAGED_ROOT_FILES.filter((relative) => {
991
+ try {
992
+ const filePath = path.join(path.resolve(root), relative);
993
+ const stat = fs.lstatSync(filePath);
994
+ if (!stat.isFile() || stat.size > 2 * 1024 * 1024) return false;
995
+ return relative === path.join('.marrow', 'passive-runtime.mjs')
996
+ || MARROW_MANAGED_TEXT_RE.test(fs.readFileSync(filePath, 'utf8'));
997
+ } catch {
998
+ return false;
999
+ }
1000
+ });
1001
+ }
1002
+
1003
+ function shellArgument(value) {
1004
+ return /^[A-Za-z0-9_./:@%+=-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
1005
+ }
1006
+
1007
+ function assertUpdateTargetsManagedRoot(detection) {
1008
+ const root = path.resolve(detection.root);
1009
+ const home = path.resolve(detection.home);
1010
+ if (root === home || marrowManagedRootFiles(root).length > 0) return;
1011
+ if (marrowManagedRootFiles(home).length === 0) return;
1012
+ throw new Error([
1013
+ `Refusing to update ${root}: it has no Marrow-managed files, and update never creates new Marrow project files in an unmanaged directory.`,
1014
+ `Marrow-managed configuration was found at ${home}. To update it, run: ${INSTALLER_UPDATE_COMMAND} --cwd ${shellArgument(home)}`,
1015
+ `To install Marrow into ${root} deliberately, run the install command from that directory instead: npx -y @getmarrow/install@latest`,
1016
+ ].join('\n'));
1017
+ }
1018
+
971
1019
  function findLikelyEnvFiles(detection, env = process.env) {
972
1020
  const home = env.HOME || env.USERPROFILE || os.homedir();
973
1021
  const candidates = [
@@ -1248,6 +1296,28 @@ function parseJsonObject(filePath) {
1248
1296
  return parsed;
1249
1297
  }
1250
1298
 
1299
+ function canonicalJsonValue(value) {
1300
+ if (Array.isArray(value)) return value.map(canonicalJsonValue);
1301
+ if (!value || typeof value !== 'object') return value;
1302
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalJsonValue(value[key])]));
1303
+ }
1304
+
1305
+ function sameJsonDocument(left, right) {
1306
+ try {
1307
+ return JSON.stringify(canonicalJsonValue(JSON.parse(left.trim())))
1308
+ === JSON.stringify(canonicalJsonValue(JSON.parse(right.trim())));
1309
+ } catch {
1310
+ return false;
1311
+ }
1312
+ }
1313
+
1314
+ // JSON.parse keeps the last duplicate key, so equal documents keep their bytes only when the
1315
+ // raw text also pins the same MCP versions; otherwise a stale earlier duplicate is never repaired.
1316
+ function equivalentManagedJson(before, after) {
1317
+ const pinnedVersions = (text) => [...new Set(mcpVersionsInText(text))].sort().join(',');
1318
+ return sameJsonDocument(before, after) && pinnedVersions(before) === pinnedVersions(after);
1319
+ }
1320
+
1251
1321
  function upsertBlock(content, block) {
1252
1322
  if (content.includes(MARROW_BLOCK_START) && content.includes(MARROW_BLOCK_END)) {
1253
1323
  const start = content.indexOf(MARROW_BLOCK_START);
@@ -2598,12 +2668,14 @@ function applyPlan(plan, options) {
2598
2668
  after = upsertBlock(before, write.block);
2599
2669
  } else if (write.type === 'json-transform') {
2600
2670
  after = write.transform(write.path);
2671
+ if (equivalentManagedJson(before, after)) after = before;
2601
2672
  } else if (write.type === 'managed-json-transform') {
2602
2673
  if (fileExists && !write.isManaged(write.path)) {
2603
2674
  after = before;
2604
2675
  hookConflict = true;
2605
2676
  } else {
2606
2677
  after = write.transform(write.path);
2678
+ if (equivalentManagedJson(before, after)) after = before;
2607
2679
  }
2608
2680
  } else if (write.type === 'owned-executable') {
2609
2681
  if (fileExists && before !== write.content) {
@@ -2697,11 +2769,14 @@ function selfTestRetryDelayMs(response, data, override) {
2697
2769
  return Math.min(delay, SELF_TEST_MAX_RETRY_DELAY_MS);
2698
2770
  }
2699
2771
 
2700
- // A first write can meet a transient 5xx, or a durable "pending" acknowledgement
2701
- // that has no decision id yet. Marrow's contract is to resend the identical request
2702
- // with the same Idempotency-Key, so a retry can never create a second record.
2772
+ // A first write can meet a transient 5xx, or a durable "pending" acknowledgement.
2773
+ // Marrow's contract is to resend the identical request with the same Idempotency-Key,
2774
+ // so a retry can never create a second record. As in the MCP client, a pending
2775
+ // acknowledgement is never complete, even when it names a created decision: that id
2776
+ // is pinned and the final response must carry the same one.
2703
2777
  async function selfTestWrite(url, options, { idempotencyKey, complete, retryDelayMs }) {
2704
2778
  let lastState = 'no response';
2779
+ let pinnedDecisionId = null;
2705
2780
  for (let attempt = 1; attempt <= SELF_TEST_WRITE_ATTEMPTS; attempt += 1) {
2706
2781
  const res = await fetch(url, { ...options, headers: { ...options.headers, 'idempotency-key': idempotencyKey } });
2707
2782
  const text = await res.text();
@@ -2713,10 +2788,18 @@ async function selfTestWrite(url, options, { idempotencyKey, complete, retryDela
2713
2788
  }
2714
2789
  const data = json.data || json;
2715
2790
  if (res.ok) {
2716
- const result = complete(data);
2717
- if (result !== undefined) return result;
2718
- const pending = data?.retryable === true && data?.committed === false && data?.idempotency_key === idempotencyKey;
2719
- if (!pending) return undefined;
2791
+ if (data?.idempotency_key !== undefined && data.idempotency_key !== idempotencyKey) {
2792
+ throw new Error('self-test write returned a different idempotency key');
2793
+ }
2794
+ const decisionId = selfTestDecisionId(data);
2795
+ if (pinnedDecisionId && decisionId && decisionId !== pinnedDecisionId) {
2796
+ throw new Error('self-test write returned a different decision id than its pending acknowledgement');
2797
+ }
2798
+ if (data?.retryable !== true || data?.committed !== false) return complete(data);
2799
+ if (data.idempotency_key !== idempotencyKey) {
2800
+ throw new Error('self-test write returned a pending acknowledgement without its idempotency key');
2801
+ }
2802
+ if (decisionId && data.decision_state === 'created') pinnedDecisionId = decisionId;
2720
2803
  lastState = 'pending';
2721
2804
  } else if (SELF_TEST_TRANSIENT_STATUSES.has(res.status)) {
2722
2805
  const reason = String(json.error || json.message || '').slice(0, 200);
@@ -2733,9 +2816,7 @@ async function selfTestWrite(url, options, { idempotencyKey, complete, retryDela
2733
2816
 
2734
2817
  function selfTestDecisionId(data) {
2735
2818
  const decisionId = data?.decision_id || data?.decisionId;
2736
- if (typeof decisionId !== 'string' || !decisionId) return undefined;
2737
- const pending = data.retryable === true && data.committed === false;
2738
- return !pending || data.decision_state === 'created' ? decisionId : undefined;
2819
+ return typeof decisionId === 'string' && decisionId ? decisionId : undefined;
2739
2820
  }
2740
2821
 
2741
2822
  function runLoopGuardSelfTest(options = {}) {
@@ -2870,7 +2951,7 @@ async function runSelfTest(options) {
2870
2951
  }),
2871
2952
  }, {
2872
2953
  idempotencyKey: `${selfTestKey}:commit`,
2873
- complete: (data) => (data?.retryable === true && data?.committed === false ? undefined : data),
2954
+ complete: (data) => data,
2874
2955
  retryDelayMs: options.selfTestRetryDelayMs,
2875
2956
  });
2876
2957
  if (commit?.failed) {
@@ -3358,6 +3439,7 @@ function printReport(report) {
3358
3439
  process.stdout.write('\nAutomatic controller:\n');
3359
3440
  process.stdout.write(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
3360
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`);
3361
3443
  if (report.controller?.exact_fix) process.stdout.write(`- exact fix: ${report.controller.exact_fix}\n`);
3362
3444
 
3363
3445
  if (report.writeMode === 'doctor') {
@@ -3412,6 +3494,8 @@ function printReport(report) {
3412
3494
  }
3413
3495
 
3414
3496
  async function install(options) {
3497
+ // The controller keeps the stable local id for its identity, but only sends a configured id.
3498
+ const configuredAgentId = String(options.agentId || '').trim();
3415
3499
  if (options.activate && (options.yes !== true || options.dryRun || options.doctor)) {
3416
3500
  throw new Error('activate requires write mode (--yes) because hooks must be installed during this run');
3417
3501
  }
@@ -3432,6 +3516,7 @@ async function install(options) {
3432
3516
  HOME: options.home || options.cwd,
3433
3517
  USERPROFILE: options.home || options.cwd,
3434
3518
  });
3519
+ if (options.repair || options.update) assertUpdateTargetsManagedRoot(detection);
3435
3520
  const client = detectedClient(detection);
3436
3521
  options.client = client;
3437
3522
  options.agentId = String(options.agentId || '').trim() || stableAgentId(detection.root, client);
@@ -3620,7 +3705,8 @@ async function install(options) {
3620
3705
  controller = await ensureGovernanceController({
3621
3706
  apiKey: options.apiKey,
3622
3707
  baseUrl: options.baseUrl,
3623
- agentId: options.agentId,
3708
+ agentId: configuredAgentId,
3709
+ identityAgentId: options.agentId,
3624
3710
  client,
3625
3711
  root: detection.root,
3626
3712
  mode: plan.mode,
@@ -3639,6 +3725,19 @@ async function install(options) {
3639
3725
  if (options.activate) throw new Error(`Marrow activation failed: local controller did not start: ${message}`);
3640
3726
  }
3641
3727
  }
3728
+ // Only a controller that is simply not running is expected while the owner has disabled
3729
+ // local control. Unsafe state, an unverified or unresponsive process, and unsupported
3730
+ // platforms keep their exact fix.
3731
+ if (localControl.state === 'disabled'
3732
+ && controllerSupportedPlatform(controllerPlatform)
3733
+ && ['stopped', 'stale'].includes(controller.state)) {
3734
+ controller = {
3735
+ ...controller,
3736
+ required: false,
3737
+ exact_fix: null,
3738
+ reason: 'Local control is disabled by the owner, so the controller is intentionally not started. It starts again on the next install or update after npx @getmarrow/install control enable.',
3739
+ };
3740
+ }
3642
3741
  const remediation = options.repair
3643
3742
  ? {
3644
3743
  attempted: true,