@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 +20 -1
- package/package.json +1 -1
- package/src/controller-manager.js +11 -5
- package/src/enforcement-client.js +28 -10
- package/src/governance-sidecar.js +128 -18
- package/src/governed-runner.js +21 -13
- package/src/installer.js +114 -15
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
300
|
+
latestMaintenance = await handlers.maintain();
|
|
218
301
|
} catch {
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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 };
|
package/src/governed-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
15
|
-
const MCP_ADAPTER_SOURCE_SHA = '
|
|
16
|
-
const MCP_ADAPTER_INTEGRITY = 'sha512-
|
|
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
|
-
//
|
|
2702
|
-
//
|
|
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
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
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
|
-
|
|
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) =>
|
|
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:
|
|
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,
|