@getmarrow/install 0.1.63 → 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 +24 -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 +175 -8
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,28 @@ 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
|
|
124
|
+
|
|
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.
|
|
126
|
+
|
|
127
|
+
### Previous release: v0.1.63
|
|
105
128
|
|
|
106
129
|
v0.1.63 pins MCP `3.9.95` (source `967b17735b26534b7dc0536ec93c481a5dc07297`, packed integrity `sha512-Sw8RIyxHkjxllwwh+9za5ItoM9X0NwN4R8+Qdd7nvOuU6qAm9LotIC09GOJDim3o9jc9m8yTqxKciroRTS12EQ==`) while keeping SDK `3.7.64` unchanged. The embedded MCP pin requires this installer patch to deliver the stdin usage-loss correction and safe bounded native Codex capture. Supported transcript schema is Codex `0.157.1`; capture covers only the latest proven model-call delta. Unknown versions, missing model/turn or billing metadata, unsafe paths and unproven subagent bindings remain incomplete or unpriced. Capture does not prove a baseline, complete overhead or savings. Restart the owning harness and run `doctor --self-test` after updating. Package publication and registry integrity verification must precede claiming these candidate pins are publicly available.
|
|
107
130
|
|
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) {
|
|
@@ -2684,6 +2756,69 @@ async function requestJson(url, options) {
|
|
|
2684
2756
|
return json.data || json;
|
|
2685
2757
|
}
|
|
2686
2758
|
|
|
2759
|
+
const SELF_TEST_WRITE_ATTEMPTS = 3;
|
|
2760
|
+
const SELF_TEST_RETRY_DELAY_MS = 1_000;
|
|
2761
|
+
const SELF_TEST_MAX_RETRY_DELAY_MS = 2_000;
|
|
2762
|
+
const SELF_TEST_TRANSIENT_STATUSES = new Set([429, 502, 503, 504]);
|
|
2763
|
+
|
|
2764
|
+
function selfTestRetryDelayMs(response, data, override) {
|
|
2765
|
+
if (Number.isFinite(override) && override >= 0) return override;
|
|
2766
|
+
const hinted = Number(data?.retry_after_ms);
|
|
2767
|
+
const header = Number(response?.headers?.get?.('retry-after')) * 1000;
|
|
2768
|
+
const delay = hinted > 0 ? hinted : header > 0 ? header : SELF_TEST_RETRY_DELAY_MS;
|
|
2769
|
+
return Math.min(delay, SELF_TEST_MAX_RETRY_DELAY_MS);
|
|
2770
|
+
}
|
|
2771
|
+
|
|
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.
|
|
2777
|
+
async function selfTestWrite(url, options, { idempotencyKey, complete, retryDelayMs }) {
|
|
2778
|
+
let lastState = 'no response';
|
|
2779
|
+
let pinnedDecisionId = null;
|
|
2780
|
+
for (let attempt = 1; attempt <= SELF_TEST_WRITE_ATTEMPTS; attempt += 1) {
|
|
2781
|
+
const res = await fetch(url, { ...options, headers: { ...options.headers, 'idempotency-key': idempotencyKey } });
|
|
2782
|
+
const text = await res.text();
|
|
2783
|
+
let json = {};
|
|
2784
|
+
try {
|
|
2785
|
+
json = text ? JSON.parse(text) : {};
|
|
2786
|
+
} catch {
|
|
2787
|
+
json = { raw: text.slice(0, 500) };
|
|
2788
|
+
}
|
|
2789
|
+
const data = json.data || json;
|
|
2790
|
+
if (res.ok) {
|
|
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;
|
|
2803
|
+
lastState = 'pending';
|
|
2804
|
+
} else if (SELF_TEST_TRANSIENT_STATUSES.has(res.status)) {
|
|
2805
|
+
const reason = String(json.error || json.message || '').slice(0, 200);
|
|
2806
|
+
lastState = reason ? `HTTP ${res.status}: ${reason}` : `HTTP ${res.status}`;
|
|
2807
|
+
} else {
|
|
2808
|
+
throw new Error(String(json.error || json.message || `HTTP ${res.status}`));
|
|
2809
|
+
}
|
|
2810
|
+
if (attempt < SELF_TEST_WRITE_ATTEMPTS) {
|
|
2811
|
+
await new Promise((resolve) => setTimeout(resolve, selfTestRetryDelayMs(res, data, retryDelayMs)));
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
return { failed: lastState };
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
function selfTestDecisionId(data) {
|
|
2818
|
+
const decisionId = data?.decision_id || data?.decisionId;
|
|
2819
|
+
return typeof decisionId === 'string' && decisionId ? decisionId : undefined;
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2687
2822
|
function runLoopGuardSelfTest(options = {}) {
|
|
2688
2823
|
const target = executableMcpTarget(options);
|
|
2689
2824
|
const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'marrow-install-loop-guard-'));
|
|
@@ -2782,7 +2917,8 @@ async function runSelfTest(options) {
|
|
|
2782
2917
|
if (options.agentId) headers['x-marrow-agent-id'] = options.agentId;
|
|
2783
2918
|
|
|
2784
2919
|
const baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
2785
|
-
const
|
|
2920
|
+
const selfTestKey = `install-self-test:${crypto.randomUUID()}`;
|
|
2921
|
+
const think = await selfTestWrite(`${baseUrl}/v1/agent/think`, {
|
|
2786
2922
|
method: 'POST',
|
|
2787
2923
|
headers,
|
|
2788
2924
|
body: JSON.stringify({
|
|
@@ -2794,12 +2930,18 @@ async function runSelfTest(options) {
|
|
|
2794
2930
|
user_intent: 'operate',
|
|
2795
2931
|
},
|
|
2796
2932
|
}),
|
|
2933
|
+
}, {
|
|
2934
|
+
idempotencyKey: `${selfTestKey}:think`,
|
|
2935
|
+
complete: (data) => selfTestDecisionId(data),
|
|
2936
|
+
retryDelayMs: options.selfTestRetryDelayMs,
|
|
2797
2937
|
});
|
|
2798
2938
|
|
|
2799
|
-
const decisionId = think
|
|
2800
|
-
if (!decisionId)
|
|
2939
|
+
const decisionId = typeof think === 'string' ? think : undefined;
|
|
2940
|
+
if (!decisionId) {
|
|
2941
|
+
throw new Error(`self-test did not return decision_id${think?.failed ? ` after ${SELF_TEST_WRITE_ATTEMPTS} attempts (last: ${think.failed})` : ''}`);
|
|
2942
|
+
}
|
|
2801
2943
|
|
|
2802
|
-
await
|
|
2944
|
+
const commit = await selfTestWrite(`${baseUrl}/v1/agent/commit`, {
|
|
2803
2945
|
method: 'POST',
|
|
2804
2946
|
headers,
|
|
2805
2947
|
body: JSON.stringify({
|
|
@@ -2807,7 +2949,14 @@ async function runSelfTest(options) {
|
|
|
2807
2949
|
success: true,
|
|
2808
2950
|
outcome: 'Marrow passive installer self-test completed successfully',
|
|
2809
2951
|
}),
|
|
2952
|
+
}, {
|
|
2953
|
+
idempotencyKey: `${selfTestKey}:commit`,
|
|
2954
|
+
complete: (data) => data,
|
|
2955
|
+
retryDelayMs: options.selfTestRetryDelayMs,
|
|
2810
2956
|
});
|
|
2957
|
+
if (commit?.failed) {
|
|
2958
|
+
throw new Error(`self-test commit did not complete after ${SELF_TEST_WRITE_ATTEMPTS} attempts (last: ${commit.failed})`);
|
|
2959
|
+
}
|
|
2811
2960
|
|
|
2812
2961
|
const status = await requestJson(`${baseUrl}/v1/agent/status`, { headers });
|
|
2813
2962
|
const context = await requestJson(`${baseUrl}/v1/agent/context`, { headers })
|
|
@@ -3290,6 +3439,7 @@ function printReport(report) {
|
|
|
3290
3439
|
process.stdout.write('\nAutomatic controller:\n');
|
|
3291
3440
|
process.stdout.write(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
|
|
3292
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`);
|
|
3293
3443
|
if (report.controller?.exact_fix) process.stdout.write(`- exact fix: ${report.controller.exact_fix}\n`);
|
|
3294
3444
|
|
|
3295
3445
|
if (report.writeMode === 'doctor') {
|
|
@@ -3344,6 +3494,8 @@ function printReport(report) {
|
|
|
3344
3494
|
}
|
|
3345
3495
|
|
|
3346
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();
|
|
3347
3499
|
if (options.activate && (options.yes !== true || options.dryRun || options.doctor)) {
|
|
3348
3500
|
throw new Error('activate requires write mode (--yes) because hooks must be installed during this run');
|
|
3349
3501
|
}
|
|
@@ -3364,6 +3516,7 @@ async function install(options) {
|
|
|
3364
3516
|
HOME: options.home || options.cwd,
|
|
3365
3517
|
USERPROFILE: options.home || options.cwd,
|
|
3366
3518
|
});
|
|
3519
|
+
if (options.repair || options.update) assertUpdateTargetsManagedRoot(detection);
|
|
3367
3520
|
const client = detectedClient(detection);
|
|
3368
3521
|
options.client = client;
|
|
3369
3522
|
options.agentId = String(options.agentId || '').trim() || stableAgentId(detection.root, client);
|
|
@@ -3552,7 +3705,8 @@ async function install(options) {
|
|
|
3552
3705
|
controller = await ensureGovernanceController({
|
|
3553
3706
|
apiKey: options.apiKey,
|
|
3554
3707
|
baseUrl: options.baseUrl,
|
|
3555
|
-
agentId:
|
|
3708
|
+
agentId: configuredAgentId,
|
|
3709
|
+
identityAgentId: options.agentId,
|
|
3556
3710
|
client,
|
|
3557
3711
|
root: detection.root,
|
|
3558
3712
|
mode: plan.mode,
|
|
@@ -3571,6 +3725,19 @@ async function install(options) {
|
|
|
3571
3725
|
if (options.activate) throw new Error(`Marrow activation failed: local controller did not start: ${message}`);
|
|
3572
3726
|
}
|
|
3573
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
|
+
}
|
|
3574
3741
|
const remediation = options.repair
|
|
3575
3742
|
? {
|
|
3576
3743
|
attempted: true,
|