@getmarrow/install 0.1.64 → 0.1.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -21
- package/package.json +1 -1
- package/src/controller-manager.js +163 -11
- package/src/enforcement-client.js +34 -13
- package/src/first-hour.js +17 -3
- package/src/governance-sidecar.js +134 -18
- package/src/governed-runner.js +430 -99
- package/src/hermes-config.js +568 -0
- package/src/installer.js +875 -385
- package/src/owner-env.js +134 -0
package/src/governed-runner.js
CHANGED
|
@@ -16,9 +16,9 @@ const {
|
|
|
16
16
|
const { startGovernanceSidecar } = require('./governance-sidecar');
|
|
17
17
|
const {
|
|
18
18
|
controllerStatus,
|
|
19
|
-
|
|
19
|
+
ensureCurrentGovernanceController,
|
|
20
20
|
startGovernanceController,
|
|
21
|
-
|
|
21
|
+
stopProjectControllers,
|
|
22
22
|
} = require('./controller-manager');
|
|
23
23
|
const {
|
|
24
24
|
ADAPTER_PROVENANCE,
|
|
@@ -26,6 +26,8 @@ const {
|
|
|
26
26
|
applyPlan,
|
|
27
27
|
buildPlan,
|
|
28
28
|
detectEnvironment,
|
|
29
|
+
detectedClient,
|
|
30
|
+
localControllerAgentId,
|
|
29
31
|
} = require('./installer');
|
|
30
32
|
const { createHostUsageCapture } = require('./usage-telemetry');
|
|
31
33
|
const { readLocalControlState, recordGovernedBypass } = require('./control-state');
|
|
@@ -91,10 +93,10 @@ const GOVERN_TUI_ROW_COUNT = 7;
|
|
|
91
93
|
const FLEET_TUI_ROW_COUNT = 12;
|
|
92
94
|
function usage() {
|
|
93
95
|
return `Usage:
|
|
94
|
-
npx @getmarrow/install run --
|
|
95
|
-
npx @getmarrow/install run --
|
|
96
|
+
npx @getmarrow/install run -- npm test
|
|
97
|
+
npx @getmarrow/install run --type deploy --policy enforce -- wrangler deploy
|
|
96
98
|
npx @getmarrow/install gate "deploy production worker"
|
|
97
|
-
npx @getmarrow/install proof --decision-id <id> --success --summary "smoke passed"
|
|
99
|
+
npx @getmarrow/install proof --decision-id <id> --gate-receipt <id> --success --summary "smoke passed"
|
|
98
100
|
npx @getmarrow/install status
|
|
99
101
|
npx @getmarrow/install permit --action "deploy production" --type deploy
|
|
100
102
|
MARROW_ACTION_PERMIT=... npx @getmarrow/install verify-permit --action "deploy production" --type deploy
|
|
@@ -125,7 +127,8 @@ Commands:
|
|
|
125
127
|
openclaw Show and verify the Marrow add-on path for OpenClaw
|
|
126
128
|
|
|
127
129
|
Options:
|
|
128
|
-
--agent <id>
|
|
130
|
+
--agent <id> Registered agent id. Defaults to MARROW_FLEET_AGENT_ID or MARROW_AGENT_ID.
|
|
131
|
+
When unset, Marrow uses the API key's bound agent or the plan's agent seat.
|
|
129
132
|
--session <id> Session id. Defaults to marrow-run-<timestamp>
|
|
130
133
|
--type <type> Action type. Inferred from action/command when omitted
|
|
131
134
|
--action <text> Human-readable action. Defaults to the redacted command
|
|
@@ -138,6 +141,7 @@ Options:
|
|
|
138
141
|
--target <text> Protected target binding, such as repository/environment
|
|
139
142
|
--sidecar-port <port> Loopback sidecar port. Default: ephemeral
|
|
140
143
|
--proof-file <path> JSON proof to include on outcome commit
|
|
144
|
+
--gate-receipt <id> proof only: the gate receipt printed by gate, for decisions the gate created
|
|
141
145
|
--client <label> Harness/client label. Defaults to MARROW_CLIENT, MARROW_HARNESS, or MARROW_AGENT_CLIENT
|
|
142
146
|
--base-url <url> Marrow API base URL
|
|
143
147
|
--key <key> Marrow API key. Prefer MARROW_API_KEY
|
|
@@ -352,7 +356,9 @@ function parseBaseOptions(argv, startIndex = 0) {
|
|
|
352
356
|
const options = {
|
|
353
357
|
apiKey: process.env.MARROW_API_KEY || process.env.MARROW_KEY || '',
|
|
354
358
|
baseUrl: process.env.MARROW_BASE_URL || DEFAULT_BASE_URL,
|
|
355
|
-
|
|
359
|
+
// A local username is never a registered agent. With no configured id, the server
|
|
360
|
+
// resolves the key-bound agent or the plan seat instead.
|
|
361
|
+
agentId: process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || '',
|
|
356
362
|
sessionId: process.env.MARROW_SESSION_ID || '',
|
|
357
363
|
profile: process.env.MARROW_GOVERN_PROFILE || 'default',
|
|
358
364
|
policy: process.env.MARROW_GOVERN_POLICY || 'enforce',
|
|
@@ -368,6 +374,10 @@ function parseBaseOptions(argv, startIndex = 0) {
|
|
|
368
374
|
permit: process.env.MARROW_ACTION_PERMIT || '',
|
|
369
375
|
target: process.env.MARROW_ACTION_TARGET || '',
|
|
370
376
|
sidecarPort: process.env.MARROW_SIDECAR_PORT || '0',
|
|
377
|
+
// One random nonce per CLI invocation. Retries inside this invocation reuse it, so a retry
|
|
378
|
+
// replays only its own request; a later run, gate or permit in the same session gets new
|
|
379
|
+
// keys and is never answered with an earlier invocation's stored response.
|
|
380
|
+
invocationNonce: crypto.randomBytes(16).toString('hex'),
|
|
371
381
|
};
|
|
372
382
|
let i = startIndex;
|
|
373
383
|
for (; i < argv.length; i += 1) {
|
|
@@ -390,7 +400,10 @@ function parseBaseOptions(argv, startIndex = 0) {
|
|
|
390
400
|
else if (arg === '--target') options.target = argv[++i] || options.target;
|
|
391
401
|
else if (arg === '--sidecar-port') options.sidecarPort = argv[++i] || options.sidecarPort;
|
|
392
402
|
else if (arg === '--proof-file') options.proofFile = argv[++i] || options.proofFile;
|
|
393
|
-
else if (arg === '--client' || arg === '--harness')
|
|
403
|
+
else if (arg === '--client' || arg === '--harness') {
|
|
404
|
+
options.client = sourceClient(argv[++i] || options.client);
|
|
405
|
+
options.clientExplicit = true;
|
|
406
|
+
}
|
|
394
407
|
else if (arg === '--base-url') options.baseUrl = argv[++i] || options.baseUrl;
|
|
395
408
|
else if (arg === '--key') {
|
|
396
409
|
options.apiKey = argv[++i] || options.apiKey;
|
|
@@ -416,7 +429,10 @@ function parseArgs(argv) {
|
|
|
416
429
|
if (command === 'run') {
|
|
417
430
|
const parsed = parseBaseOptions(argv, 1);
|
|
418
431
|
const separator = argv[parsed.index] === '--' ? parsed.index + 1 : parsed.index;
|
|
419
|
-
|
|
432
|
+
let childCommand = argv.slice(separator);
|
|
433
|
+
// npx forwards both separators of the documented `run -- -- <command>` form. No real
|
|
434
|
+
// command is named `--`, so one extra leading separator is dropped instead of spawned.
|
|
435
|
+
if (childCommand[0] === '--') childCommand = childCommand.slice(1);
|
|
420
436
|
if (parsed.options.help) return { command: 'help' };
|
|
421
437
|
if (childCommand.length === 0) throw new Error('marrow run requires a command after --');
|
|
422
438
|
return { command, options: parsed.options, childCommand };
|
|
@@ -442,18 +458,24 @@ function parseArgs(argv) {
|
|
|
442
458
|
}
|
|
443
459
|
|
|
444
460
|
if (command === 'proof') {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
461
|
+
// Proof flags are taken out first; the shared parser rejects options it does not know,
|
|
462
|
+
// which made every documented `proof --decision-id ...` call fail before 0.1.66.
|
|
463
|
+
const proofOptions = { decisionId: '', gateReceiptId: '', success: true, summary: '', outcome: '' };
|
|
464
|
+
const baseArgv = [command];
|
|
465
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
448
466
|
const arg = argv[i];
|
|
449
|
-
if (arg === '--decision-id')
|
|
450
|
-
else if (arg === '--
|
|
451
|
-
else if (arg === '--
|
|
452
|
-
else if (arg === '--
|
|
453
|
-
else if (arg === '--
|
|
454
|
-
else if (arg === '--
|
|
455
|
-
else
|
|
467
|
+
if (arg === '--decision-id') proofOptions.decisionId = argv[++i] || '';
|
|
468
|
+
else if (arg === '--gate-receipt' || arg === '--gate-receipt-id') proofOptions.gateReceiptId = argv[++i] || '';
|
|
469
|
+
else if (arg === '--success') proofOptions.success = true;
|
|
470
|
+
else if (arg === '--failure' || arg === '--failed') proofOptions.success = false;
|
|
471
|
+
else if (arg === '--summary') proofOptions.summary = argv[++i] || '';
|
|
472
|
+
else if (arg === '--outcome') proofOptions.outcome = argv[++i] || '';
|
|
473
|
+
else baseArgv.push(arg);
|
|
456
474
|
}
|
|
475
|
+
const parsed = parseBaseOptions(baseArgv, 1);
|
|
476
|
+
if (parsed.options.help) return { command: 'help' };
|
|
477
|
+
if (parsed.index < baseArgv.length) throw new Error(`Unknown proof option: ${baseArgv[parsed.index]}`);
|
|
478
|
+
const options = { ...parsed.options, ...proofOptions };
|
|
457
479
|
if (!options.decisionId) throw new Error('marrow proof requires --decision-id');
|
|
458
480
|
return { command, options };
|
|
459
481
|
}
|
|
@@ -481,7 +503,6 @@ function headers(options) {
|
|
|
481
503
|
const h = {
|
|
482
504
|
Authorization: `Bearer ${options.apiKey}`,
|
|
483
505
|
'Content-Type': 'application/json',
|
|
484
|
-
'X-Marrow-Agent-Id': options.agentId,
|
|
485
506
|
'X-Marrow-Session-Id': options.sessionId,
|
|
486
507
|
'X-Marrow-Client': sourceClient(options.client),
|
|
487
508
|
'X-Marrow-Package': '@getmarrow/install',
|
|
@@ -491,6 +512,7 @@ function headers(options) {
|
|
|
491
512
|
'X-Marrow-MCP-Version': ADAPTER_PROVENANCE.mcp.version,
|
|
492
513
|
'User-Agent': '@getmarrow/install governed-runner',
|
|
493
514
|
};
|
|
515
|
+
if (options.agentId) h['X-Marrow-Agent-Id'] = options.agentId;
|
|
494
516
|
return h;
|
|
495
517
|
}
|
|
496
518
|
|
|
@@ -501,7 +523,7 @@ function sourceMeta(options, channel, extra = {}) {
|
|
|
501
523
|
client,
|
|
502
524
|
harness: client,
|
|
503
525
|
runner: '@getmarrow/install',
|
|
504
|
-
agent_id: options.agentId,
|
|
526
|
+
...(options.agentId ? { agent_id: options.agentId } : {}),
|
|
505
527
|
session_id: options.sessionId,
|
|
506
528
|
profile: options.profile,
|
|
507
529
|
governed: true,
|
|
@@ -514,25 +536,122 @@ function dataOf(json) {
|
|
|
514
536
|
return json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : json;
|
|
515
537
|
}
|
|
516
538
|
|
|
517
|
-
async function
|
|
539
|
+
async function rawRequest(options, method, route, body, extraHeaders = {}, { timeoutMs } = {}) {
|
|
518
540
|
if (!options.apiKey) throw new Error('MARROW_API_KEY is required. Use --fail-open only for non-production local commands.');
|
|
519
541
|
const response = await fetch(new URL(route, options.baseUrl.replace(/\/$/, '/')), {
|
|
520
542
|
method,
|
|
521
543
|
headers: { ...headers(options), ...extraHeaders },
|
|
522
544
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
545
|
+
...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
|
|
523
546
|
});
|
|
524
547
|
const text = await response.text();
|
|
525
548
|
let json = {};
|
|
526
549
|
try { json = text ? JSON.parse(text) : {}; } catch { json = { error: text.slice(0, 500) }; }
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
550
|
+
return { response, json };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function responseError(route, response, json) {
|
|
554
|
+
const error = new Error(json.error || json.message || `Marrow ${route} returned HTTP ${response.status}`);
|
|
555
|
+
error.status = response.status;
|
|
556
|
+
error.details = json.details || json;
|
|
557
|
+
return error;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function requestJson(options, method, route, body, extraHeaders = {}, requestOptions = {}) {
|
|
561
|
+
const { response, json } = await rawRequest(options, method, route, body, extraHeaders, requestOptions);
|
|
562
|
+
if (!response.ok) throw responseError(route, response, json);
|
|
533
563
|
return dataOf(json);
|
|
534
564
|
}
|
|
535
565
|
|
|
566
|
+
const DURABLE_WRITE_ATTEMPTS = 3;
|
|
567
|
+
const DURABLE_RETRY_DELAY_MS = 1_000;
|
|
568
|
+
const DURABLE_MAX_RETRY_DELAY_MS = 2_000;
|
|
569
|
+
const DURABLE_ATTEMPT_TIMEOUT_MS = 10_000;
|
|
570
|
+
const DURABLE_TOTAL_TIMEOUT_MS = 25_000;
|
|
571
|
+
const DURABLE_TRANSIENT_STATUSES = new Set([429, 502, 503, 504]);
|
|
572
|
+
|
|
573
|
+
function durableIdempotencyKey(options, route, parts) {
|
|
574
|
+
const digest = crypto.createHash('sha256')
|
|
575
|
+
.update([options.invocationNonce, options.sessionId, route, ...parts].map((part) => String(part ?? '')).join('\u0000'))
|
|
576
|
+
.digest('hex');
|
|
577
|
+
return `marrow-run-${route.split('/').pop()}-${digest.slice(0, 48)}`;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function durableRetryDelayMs(response, data, options) {
|
|
581
|
+
if (Number.isFinite(options.retryDelayMs) && options.retryDelayMs >= 0) return options.retryDelayMs;
|
|
582
|
+
const hinted = Number(data?.retry_after_ms);
|
|
583
|
+
const header = Number(response?.headers?.get?.('retry-after')) * 1000;
|
|
584
|
+
const delay = hinted > 0 ? hinted : header > 0 ? header : DURABLE_RETRY_DELAY_MS;
|
|
585
|
+
return Math.min(delay, DURABLE_MAX_RETRY_DELAY_MS);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function isTimeout(error) {
|
|
589
|
+
return error?.name === 'TimeoutError' || error?.name === 'AbortError';
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Runtime, think and commit are durable Marrow writes. A transient 429/502/503/504, a timed-out
|
|
593
|
+
// attempt, or a pending acknowledgement (retryable:true, committed:false) is resent unchanged
|
|
594
|
+
// with the same Idempotency-Key, at most three attempts within one overall deadline, so a retry
|
|
595
|
+
// can never create a second record and a hung service never holds the command forever. A
|
|
596
|
+
// pending acknowledgement that names a created decision pins that id; the final answer must
|
|
597
|
+
// carry the same one. Client errors and connection failures are not retried.
|
|
598
|
+
async function durableRequestJson(options, route, body, idempotencyKey) {
|
|
599
|
+
const attemptTimeoutMs = Number.isFinite(options.requestTimeoutMs) ? options.requestTimeoutMs : DURABLE_ATTEMPT_TIMEOUT_MS;
|
|
600
|
+
const deadline = Date.now() + (Number.isFinite(options.requestDeadlineMs) ? options.requestDeadlineMs : DURABLE_TOTAL_TIMEOUT_MS);
|
|
601
|
+
let lastState = 'no response';
|
|
602
|
+
let pinnedDecisionId = null;
|
|
603
|
+
const exhausted = (status) => {
|
|
604
|
+
const error = new Error(`Marrow ${route} did not complete after ${DURABLE_WRITE_ATTEMPTS} attempts (last: ${lastState}).`);
|
|
605
|
+
error.status = status;
|
|
606
|
+
return error;
|
|
607
|
+
};
|
|
608
|
+
for (let attempt = 1; attempt <= DURABLE_WRITE_ATTEMPTS; attempt += 1) {
|
|
609
|
+
const remaining = deadline - Date.now();
|
|
610
|
+
if (remaining <= 0) {
|
|
611
|
+
const error = new Error(`Marrow ${route} did not complete before its deadline (last: ${lastState}).`);
|
|
612
|
+
error.status = 504;
|
|
613
|
+
throw error;
|
|
614
|
+
}
|
|
615
|
+
let response;
|
|
616
|
+
let json;
|
|
617
|
+
try {
|
|
618
|
+
({ response, json } = await rawRequest(options, 'POST', route, body, { 'Idempotency-Key': idempotencyKey }, {
|
|
619
|
+
timeoutMs: Math.max(1, Math.min(attemptTimeoutMs, remaining)),
|
|
620
|
+
}));
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (!isTimeout(error)) throw error;
|
|
623
|
+
lastState = 'timed out';
|
|
624
|
+
if (attempt === DURABLE_WRITE_ATTEMPTS) throw exhausted(504);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const data = dataOf(json);
|
|
628
|
+
if (response.ok) {
|
|
629
|
+
const decisionId = typeof data?.decision_id === 'string' && data.decision_id ? data.decision_id : null;
|
|
630
|
+
if (pinnedDecisionId && decisionId && decisionId !== pinnedDecisionId) {
|
|
631
|
+
throw new Error(`Marrow ${route} returned a different decision id than its pending acknowledgement.`);
|
|
632
|
+
}
|
|
633
|
+
if (data?.retryable !== true || data?.committed !== false) return data;
|
|
634
|
+
if (decisionId && data.decision_state === 'created') pinnedDecisionId = decisionId;
|
|
635
|
+
lastState = `pending${data.reconciliation_state ? ` (${String(data.reconciliation_state).slice(0, 80)})` : ''}`;
|
|
636
|
+
} else if (DURABLE_TRANSIENT_STATUSES.has(response.status)) {
|
|
637
|
+
const reason = String(json.error || json.message || '').slice(0, 200);
|
|
638
|
+
lastState = reason ? `HTTP ${response.status}: ${reason}` : `HTTP ${response.status}`;
|
|
639
|
+
if (attempt === DURABLE_WRITE_ATTEMPTS) {
|
|
640
|
+
const error = responseError(route, response, json);
|
|
641
|
+
error.message = `Marrow ${route} did not complete after ${DURABLE_WRITE_ATTEMPTS} attempts (last: ${lastState}).`;
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
} else {
|
|
645
|
+
throw responseError(route, response, json);
|
|
646
|
+
}
|
|
647
|
+
if (attempt < DURABLE_WRITE_ATTEMPTS) {
|
|
648
|
+
const wait = Math.min(durableRetryDelayMs(response, data, options), Math.max(0, deadline - Date.now()));
|
|
649
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
throw exhausted(202);
|
|
653
|
+
}
|
|
654
|
+
|
|
536
655
|
function proofFromFile(filePath) {
|
|
537
656
|
if (!filePath) return null;
|
|
538
657
|
const raw = fs.readFileSync(path.resolve(filePath), 'utf8');
|
|
@@ -574,11 +693,14 @@ async function preflightRuntime(options, action, type, commandText) {
|
|
|
574
693
|
const surfaces = inferSurfaces(commandText || action);
|
|
575
694
|
const meta = sourceMeta(options, 'runtime', { action, command: commandText, action_type: type });
|
|
576
695
|
const project = detectProjectSignals(options.root || process.cwd());
|
|
577
|
-
|
|
696
|
+
const body = {
|
|
578
697
|
action,
|
|
579
698
|
type,
|
|
580
699
|
target,
|
|
581
700
|
surfaces,
|
|
701
|
+
// Package clients get the slim runtime shape unless they ask for expanded. The runner reads
|
|
702
|
+
// risk_gate, gate_receipt and runtime_authorization, as the install self-test does.
|
|
703
|
+
response_mode: 'expanded',
|
|
582
704
|
harness: sourceClient(options.client),
|
|
583
705
|
project: { ...project, harness: sourceClient(options.client) },
|
|
584
706
|
source_meta: meta,
|
|
@@ -590,7 +712,9 @@ async function preflightRuntime(options, action, type, commandText) {
|
|
|
590
712
|
governed: true,
|
|
591
713
|
source_meta: meta,
|
|
592
714
|
},
|
|
593
|
-
}
|
|
715
|
+
};
|
|
716
|
+
return durableRequestJson(options, '/v1/agent/runtime', body,
|
|
717
|
+
durableIdempotencyKey(options, '/v1/agent/runtime', [JSON.stringify(body)]));
|
|
594
718
|
}
|
|
595
719
|
|
|
596
720
|
async function recommendGovernanceMode(options, project = detectProjectSignals()) {
|
|
@@ -613,7 +737,7 @@ async function recommendGovernanceMode(options, project = detectProjectSignals()
|
|
|
613
737
|
environment: process.env.NODE_ENV || process.env.MARROW_GOVERN_PROFILE || options.profile,
|
|
614
738
|
},
|
|
615
739
|
agent: {
|
|
616
|
-
id: options.agentId,
|
|
740
|
+
id: options.agentId || undefined,
|
|
617
741
|
role: 'setup',
|
|
618
742
|
},
|
|
619
743
|
source_meta: sourceMeta(options, 'mode_recommend', { action }),
|
|
@@ -631,7 +755,7 @@ async function recordGovernanceModeSelection(options, state) {
|
|
|
631
755
|
environment: process.env.NODE_ENV || process.env.MARROW_GOVERN_PROFILE || options.profile,
|
|
632
756
|
},
|
|
633
757
|
agent: {
|
|
634
|
-
id: options.agentId,
|
|
758
|
+
id: options.agentId || undefined,
|
|
635
759
|
role: 'setup',
|
|
636
760
|
},
|
|
637
761
|
selected_mode: selected,
|
|
@@ -640,33 +764,106 @@ async function recordGovernanceModeSelection(options, state) {
|
|
|
640
764
|
});
|
|
641
765
|
}
|
|
642
766
|
|
|
767
|
+
function recordOf(value) {
|
|
768
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function lowerText(value) {
|
|
772
|
+
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// The runtime answers in two shapes. Expanded responses carry risk_gate, gate_receipt and
|
|
776
|
+
// runtime_authorization. Slim responses, the default for package clients, carry top-level
|
|
777
|
+
// decision, enforcement_decision, risk_gate_enforced, gate_receipt_id and gate_required.
|
|
778
|
+
// Both normalize to one gate, so the runner never reads a real answer as "unknown".
|
|
643
779
|
function gateDecision(runtime) {
|
|
644
|
-
const
|
|
645
|
-
const
|
|
780
|
+
const value = recordOf(runtime);
|
|
781
|
+
const gate = recordOf(value.risk_gate);
|
|
782
|
+
const receipt = recordOf(value.gate_receipt);
|
|
783
|
+
const authorization = recordOf(value.runtime_authorization);
|
|
784
|
+
const completion = recordOf(value.completion_contract);
|
|
785
|
+
const enforced = typeof gate.enforced === 'boolean'
|
|
786
|
+
? gate.enforced
|
|
787
|
+
: typeof value.risk_gate_enforced === 'boolean'
|
|
788
|
+
? value.risk_gate_enforced
|
|
789
|
+
: null;
|
|
790
|
+
const enforcementDecision = lowerText(gate.enforcement_decision) || lowerText(value.enforcement_decision);
|
|
791
|
+
const enforcementAsDecision = enforcementDecision === 'owner_approval_required'
|
|
792
|
+
? 'review_required'
|
|
793
|
+
: enforcementDecision === 'advisory' ? '' : enforcementDecision;
|
|
794
|
+
const decision = lowerText(gate.decision) || lowerText(value.decision) || lowerText(receipt.decision)
|
|
795
|
+
|| enforcementAsDecision;
|
|
796
|
+
const recognized = Boolean(decision || enforcementDecision || enforced !== null);
|
|
797
|
+
// An observation-only answer records outcomes but never authorizes execution.
|
|
798
|
+
const observationOnly = [decision, enforcementDecision, lowerText(authorization.kind), lowerText(receipt.kind),
|
|
799
|
+
lowerText(authorization.decision_state)].includes('outcome_observation_only');
|
|
800
|
+
const authorizationWithheld = gate.authorization_granted === false || receipt.authorization_granted === false;
|
|
801
|
+
const decisionState = lowerText(authorization.decision_state) || lowerText(completion.decision_state);
|
|
802
|
+
const createdDecisionId = String(value.decision_id || authorization.decision_id || completion.decision_id || '');
|
|
646
803
|
return {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
804
|
+
recognized,
|
|
805
|
+
decision: recognized ? decision || 'proceed' : 'none',
|
|
806
|
+
enforcementDecision,
|
|
807
|
+
enforced,
|
|
808
|
+
observationOnly,
|
|
809
|
+
allow: authorizationWithheld || observationOnly ? false : typeof gate.allow === 'boolean'
|
|
810
|
+
? gate.allow
|
|
811
|
+
: typeof value.allow === 'boolean' ? value.allow : decision !== 'block',
|
|
812
|
+
riskLevel: lowerText(gate.risk_level) || lowerText(receipt.risk_level) || lowerText(value.risk_level),
|
|
813
|
+
required: Boolean(receipt.required || gate.gate_required || value.gate_required || completion.gate_receipt_required),
|
|
814
|
+
ownerApprovalRequired: Boolean(receipt.owner_approval_required || gate.owner_approval_required
|
|
815
|
+
|| completion.owner_approval_required === true || enforcementDecision === 'owner_approval_required'),
|
|
816
|
+
receiptId: String(receipt.id || gate.gate_receipt_id || value.gate_receipt_id || authorization.id
|
|
817
|
+
|| completion.gate_receipt_id || ''),
|
|
818
|
+
// A runtime that already created the decision is closed with that id and its receipt;
|
|
819
|
+
// think is called only when the runtime says decision creation is still required.
|
|
820
|
+
runtimeDecisionId: decisionState === 'not_created' ? '' : createdDecisionId,
|
|
821
|
+
exactNextAction: value.exact_next_action || receipt.exact_fix || recordOf(gate.policy).exact_fix
|
|
822
|
+
|| completion.exact_next_action || '',
|
|
823
|
+
beforeYouAct: recordOf(value.before_you_act_injection).message || value.before_you_act || '',
|
|
824
|
+
proofPack: value.proof_pack || (value.proof_required ? {
|
|
825
|
+
required: true,
|
|
826
|
+
required_fields: Array.isArray(completion.required_proof_fields) ? completion.required_proof_fields : [],
|
|
827
|
+
missing: Array.isArray(completion.missing_proof_fields) ? completion.missing_proof_fields : [],
|
|
828
|
+
} : null),
|
|
656
829
|
};
|
|
657
830
|
}
|
|
658
831
|
|
|
659
832
|
function shouldBlock(decision, options) {
|
|
833
|
+
// Marrow withheld authorization (allow:false, or an observation-only answer): blocked under
|
|
834
|
+
// every plan mode and local policy.
|
|
835
|
+
if (decision.observationOnly || decision.allow === false) return true;
|
|
660
836
|
if (options.policy === 'audit') return false;
|
|
661
|
-
|
|
837
|
+
// An advisory plan (enforced:false) never blocks locally; the runner shows the warning.
|
|
838
|
+
if (decision.enforced === false) return false;
|
|
839
|
+
if (decision.decision === 'block') return true;
|
|
662
840
|
if (options.policy === 'warn') return false;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
841
|
+
const approvalRequired = decision.ownerApprovalRequired
|
|
842
|
+
|| decision.decision === 'review_required'
|
|
843
|
+
|| decision.decision === 'owner_approval_required'
|
|
844
|
+
|| decision.enforcementDecision === 'owner_approval_required';
|
|
845
|
+
return Boolean(approvalRequired && !options.ownerApproval);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function blockMessage(decision) {
|
|
849
|
+
if (decision.observationOnly) return 'Marrow answered in observation-only mode, which cannot authorize this action. Retry for a fresh gate.';
|
|
850
|
+
return decision.exactNextAction || 'Marrow blocked this action before execution.';
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function gateModeText(decision) {
|
|
854
|
+
const parts = [decision.enforced === true
|
|
855
|
+
? 'enforced'
|
|
856
|
+
: decision.enforced === false
|
|
857
|
+
? 'advisory, not enforced on this plan'
|
|
858
|
+
: 'enforcement not reported'];
|
|
859
|
+
if (decision.riskLevel) parts.push(`risk ${decision.riskLevel}`);
|
|
860
|
+
if (decision.required) parts.push('receipt required');
|
|
861
|
+
if (decision.ownerApprovalRequired) parts.push('owner approval required');
|
|
862
|
+
return parts.join('; ');
|
|
666
863
|
}
|
|
667
864
|
|
|
668
865
|
function printGate(decision, runtime, stream = process.stdout) {
|
|
669
|
-
stream.write(`Marrow gate: ${decision.decision}${decision
|
|
866
|
+
stream.write(`Marrow gate: ${decision.decision} (${gateModeText(decision)})\n`);
|
|
670
867
|
if (decision.beforeYouAct) stream.write(`Before you act: ${decision.beforeYouAct}\n`);
|
|
671
868
|
if (decision.exactNextAction) stream.write(`Next: ${decision.exactNextAction}\n`);
|
|
672
869
|
if (decision.proofPack?.required) {
|
|
@@ -738,7 +935,7 @@ function scopedExecutionEnv(permit) {
|
|
|
738
935
|
|
|
739
936
|
async function createDecision(options, action, type, target, surfaces) {
|
|
740
937
|
const meta = sourceMeta(options, 'think', { action, action_type: type });
|
|
741
|
-
|
|
938
|
+
const body = {
|
|
742
939
|
action,
|
|
743
940
|
type,
|
|
744
941
|
target,
|
|
@@ -750,10 +947,19 @@ async function createDecision(options, action, type, target, surfaces) {
|
|
|
750
947
|
governed: true,
|
|
751
948
|
source_meta: meta,
|
|
752
949
|
},
|
|
753
|
-
}
|
|
950
|
+
};
|
|
951
|
+
return durableRequestJson(options, '/v1/agent/think', body,
|
|
952
|
+
durableIdempotencyKey(options, '/v1/agent/think', [JSON.stringify(body)]));
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// `run` folds its invocation nonce into the commit key. `proof` keeps a stable key per
|
|
956
|
+
// decision, so repeating the same proof command replays instead of recording twice.
|
|
957
|
+
function commitIdempotencyKey(options, decisionId, { perInvocation = true } = {}) {
|
|
958
|
+
const parts = [options.agentId, options.sessionId, decisionId, ...(perInvocation ? [options.invocationNonce] : [])];
|
|
959
|
+
return `marrow-run-${crypto.createHash('sha256').update(parts.join('\u0000')).digest('hex')}`;
|
|
754
960
|
}
|
|
755
961
|
|
|
756
|
-
async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId, modelUsage = null) {
|
|
962
|
+
async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId, modelUsage = null, { perInvocation = true } = {}) {
|
|
757
963
|
const body = {
|
|
758
964
|
decision_id: decisionId,
|
|
759
965
|
success,
|
|
@@ -763,12 +969,14 @@ async function commitOutcome(options, decisionId, success, outcome, proof, gateR
|
|
|
763
969
|
};
|
|
764
970
|
if (gateReceiptId) body.gate_receipt_id = gateReceiptId;
|
|
765
971
|
if (modelUsage) body.model_usage = modelUsage;
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
972
|
+
return durableRequestJson(options, '/v1/agent/commit', body, commitIdempotencyKey(options, decisionId, { perInvocation }));
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function decisionForAction(options, decision, action, type, target, surfaces) {
|
|
976
|
+
if (decision?.runtimeDecisionId) return { decisionId: decision.runtimeDecisionId, source: 'runtime' };
|
|
977
|
+
const think = await createDecision(options, action, type, target, surfaces);
|
|
978
|
+
const decisionId = think.decision_id || think.id || think.decision?.id || '';
|
|
979
|
+
return { decisionId, source: decisionId ? 'think' : null };
|
|
772
980
|
}
|
|
773
981
|
|
|
774
982
|
async function runGoverned(parsed, execution = {}) {
|
|
@@ -781,9 +989,11 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
781
989
|
let runtime = null;
|
|
782
990
|
let decision = null;
|
|
783
991
|
let decisionId = '';
|
|
992
|
+
let decisionSource = null;
|
|
784
993
|
let actionPermit = null;
|
|
785
994
|
let permitVerified = false;
|
|
786
995
|
let degraded = false;
|
|
996
|
+
let advisory = false;
|
|
787
997
|
let protectedAction = risky;
|
|
788
998
|
const surfaces = inferSurfaces(commandText || action);
|
|
789
999
|
|
|
@@ -803,10 +1013,15 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
803
1013
|
runtime = await preflightRuntime(options, action, type, commandText);
|
|
804
1014
|
decision = gateDecision(runtime);
|
|
805
1015
|
protectedAction = risky
|
|
806
|
-
|| decision
|
|
807
|
-
|| decision
|
|
808
|
-
|| decision
|
|
809
|
-
|
|
1016
|
+
|| decision.required === true
|
|
1017
|
+
|| decision.riskLevel === 'high'
|
|
1018
|
+
|| decision.riskLevel === 'critical';
|
|
1019
|
+
if (!decision.recognized) {
|
|
1020
|
+
if (protectedAction) throw new Error('Marrow runtime returned no gate decision, so this protected action cannot be checked.');
|
|
1021
|
+
process.stderr.write('Marrow gate: no decision returned; continuing because this action is not protected.\n');
|
|
1022
|
+
} else {
|
|
1023
|
+
printGate(decision, runtime);
|
|
1024
|
+
}
|
|
810
1025
|
if (shouldBlock(decision, options)) {
|
|
811
1026
|
return {
|
|
812
1027
|
ok: false,
|
|
@@ -816,22 +1031,29 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
816
1031
|
type,
|
|
817
1032
|
risky,
|
|
818
1033
|
decision,
|
|
819
|
-
message: decision
|
|
1034
|
+
message: blockMessage(decision),
|
|
820
1035
|
};
|
|
821
1036
|
}
|
|
822
1037
|
const target = options.target || commandText;
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
1038
|
+
({ decisionId, source: decisionSource } = await decisionForAction(options, decision, action, type, target, surfaces));
|
|
1039
|
+
// Permits exist only where the plan enforces the gate. An advisory plan shows its warning,
|
|
1040
|
+
// runs the command and still records the outcome; it never asks for a permit it cannot get.
|
|
1041
|
+
const permitRequired = protectedAction && decision.enforced !== false;
|
|
1042
|
+
if (protectedAction && !permitRequired) {
|
|
1043
|
+
advisory = true;
|
|
1044
|
+
process.stderr.write(`Marrow advisory: this ${type} action is not enforced on this plan (gate ${decision.decision}). Running it and recording the outcome.\n`);
|
|
1045
|
+
}
|
|
1046
|
+
if (permitRequired) {
|
|
1047
|
+
if (!decision.receiptId) throw new Error('Marrow runtime returned no gate receipt, so no action permit can be issued.');
|
|
826
1048
|
actionPermit = await issueActionPermit(requestJson, options, {
|
|
827
1049
|
action,
|
|
828
1050
|
type,
|
|
829
1051
|
target,
|
|
830
1052
|
surfaces,
|
|
831
1053
|
decisionId,
|
|
832
|
-
gateReceiptId: decision
|
|
1054
|
+
gateReceiptId: decision.receiptId,
|
|
833
1055
|
ownerApproval: options.ownerApproval,
|
|
834
|
-
proofRequirements: decision
|
|
1056
|
+
proofRequirements: decision.proofPack?.required_fields || decision.proofPack?.missing || [],
|
|
835
1057
|
});
|
|
836
1058
|
if (!actionPermit?.permit || !actionPermit?.permit_id) {
|
|
837
1059
|
throw new Error('Marrow did not issue a valid action permit.');
|
|
@@ -842,6 +1064,7 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
842
1064
|
target,
|
|
843
1065
|
surfaces,
|
|
844
1066
|
permit: actionPermit.permit,
|
|
1067
|
+
protocolVersion: actionPermit.protocol_version,
|
|
845
1068
|
});
|
|
846
1069
|
if (verified?.verified !== true) throw new Error('Marrow action permit verification failed.');
|
|
847
1070
|
permitVerified = true;
|
|
@@ -853,7 +1076,7 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
853
1076
|
actionPermit = null;
|
|
854
1077
|
permitVerified = false;
|
|
855
1078
|
degraded = true;
|
|
856
|
-
process.stderr.write(`Marrow degraded: ${error.message}. Continuing because
|
|
1079
|
+
process.stderr.write(`Marrow degraded: ${error.message}. Continuing because this action is not protected${decisionId ? '' : '; its outcome will not be recorded'}.\n`);
|
|
857
1080
|
} else {
|
|
858
1081
|
return {
|
|
859
1082
|
ok: false,
|
|
@@ -877,6 +1100,7 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
877
1100
|
: `Marrow governed command failed with exit code ${child.exitCode}.`;
|
|
878
1101
|
|
|
879
1102
|
let commit = null;
|
|
1103
|
+
let commitState = decisionId ? 'failed' : 'not_recorded';
|
|
880
1104
|
if (decisionId) {
|
|
881
1105
|
try {
|
|
882
1106
|
commit = await commitOutcome(
|
|
@@ -888,9 +1112,15 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
888
1112
|
decision?.receiptId || '',
|
|
889
1113
|
child.modelUsage,
|
|
890
1114
|
);
|
|
1115
|
+
commitState = commit?.committed === true ? 'committed' : 'not_committed';
|
|
1116
|
+
if (commitState === 'not_committed') {
|
|
1117
|
+
process.stderr.write(`Marrow recorded this outcome without trusted closure (committed:${String(commit?.committed ?? 'missing')}).\n`);
|
|
1118
|
+
}
|
|
891
1119
|
} catch (error) {
|
|
892
1120
|
process.stderr.write(`Marrow outcome commit failed: ${error.message}\n`);
|
|
893
1121
|
}
|
|
1122
|
+
} else if (!degraded) {
|
|
1123
|
+
process.stderr.write('Marrow outcome not recorded: no decision id was returned before the command ran.\n');
|
|
894
1124
|
}
|
|
895
1125
|
|
|
896
1126
|
let permitClosed = null;
|
|
@@ -899,6 +1129,7 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
899
1129
|
permitClosed = await closeActionPermit(requestJson, options, {
|
|
900
1130
|
permit: actionPermit.permit,
|
|
901
1131
|
permitId: actionPermit.permit_id,
|
|
1132
|
+
protocolVersion: actionPermit.protocol_version,
|
|
902
1133
|
decisionId,
|
|
903
1134
|
success,
|
|
904
1135
|
evidence: proof,
|
|
@@ -916,9 +1147,13 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
916
1147
|
type,
|
|
917
1148
|
risky,
|
|
918
1149
|
degraded,
|
|
1150
|
+
advisory,
|
|
919
1151
|
decision,
|
|
920
1152
|
decision_id: decisionId,
|
|
921
|
-
|
|
1153
|
+
decision_source: decisionSource,
|
|
1154
|
+
gate_receipt_id: decision?.receiptId || null,
|
|
1155
|
+
outcome_committed: commitState === 'committed',
|
|
1156
|
+
outcome_commit_state: commitState,
|
|
922
1157
|
permit_id: actionPermit?.permit_id || null,
|
|
923
1158
|
permit_verified: permitVerified,
|
|
924
1159
|
permit_closed: Boolean(permitClosed),
|
|
@@ -943,11 +1178,28 @@ async function permitOnly(parsed) {
|
|
|
943
1178
|
const surfaces = inferSurfaces(target);
|
|
944
1179
|
const runtime = await preflightRuntime(options, action, type, target);
|
|
945
1180
|
const decision = gateDecision(runtime);
|
|
1181
|
+
if (!decision.recognized) {
|
|
1182
|
+
return { ok: false, blocked: true, exitCode: 13, decision, message: 'Marrow runtime returned no gate decision, so no permit was issued.' };
|
|
1183
|
+
}
|
|
946
1184
|
if (shouldBlock(decision, options)) {
|
|
947
|
-
return { ok: false, blocked: true, exitCode: 12, decision, message: decision
|
|
1185
|
+
return { ok: false, blocked: true, exitCode: 12, decision, message: blockMessage(decision) };
|
|
1186
|
+
}
|
|
1187
|
+
const { decisionId } = await decisionForAction(options, decision, action, type, target, surfaces);
|
|
1188
|
+
if (decision.enforced === false) {
|
|
1189
|
+
return {
|
|
1190
|
+
ok: true,
|
|
1191
|
+
advisory: true,
|
|
1192
|
+
decision,
|
|
1193
|
+
decision_id: decisionId,
|
|
1194
|
+
gate_receipt_id: decision.receiptId || null,
|
|
1195
|
+
permit: null,
|
|
1196
|
+
permit_id: null,
|
|
1197
|
+
message: `This plan runs Marrow gates in advisory mode (gate ${decision.decision}), so no action permit is issued. Record the outcome with proof after the action.`,
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
if (!decision.receiptId) {
|
|
1201
|
+
return { ok: false, blocked: true, exitCode: 13, decision, message: 'Marrow runtime returned no gate receipt, so no permit was issued.' };
|
|
948
1202
|
}
|
|
949
|
-
const think = await createDecision(options, action, type, target, surfaces);
|
|
950
|
-
const decisionId = think.decision_id || think.id || think.decision?.id || '';
|
|
951
1203
|
const result = await issueActionPermit(requestJson, options, {
|
|
952
1204
|
action,
|
|
953
1205
|
type,
|
|
@@ -956,9 +1208,9 @@ async function permitOnly(parsed) {
|
|
|
956
1208
|
decisionId,
|
|
957
1209
|
gateReceiptId: decision.receiptId,
|
|
958
1210
|
ownerApproval: options.ownerApproval,
|
|
959
|
-
proofRequirements: decision
|
|
1211
|
+
proofRequirements: decision.proofPack?.required_fields || decision.proofPack?.missing || [],
|
|
960
1212
|
});
|
|
961
|
-
return { ok: true, decision_id: decisionId, ...result };
|
|
1213
|
+
return { ok: true, decision_id: decisionId, gate_receipt_id: decision.receiptId, ...result };
|
|
962
1214
|
}
|
|
963
1215
|
|
|
964
1216
|
async function verifyPermitOnly(parsed) {
|
|
@@ -990,16 +1242,32 @@ async function sidecarOnly(parsed) {
|
|
|
990
1242
|
const maintain = async () => {
|
|
991
1243
|
if (lastMaintenance && Date.now() - lastMaintenanceAt < 5 * 60_000) return lastMaintenance;
|
|
992
1244
|
const detection = detectEnvironment(root, process.env);
|
|
993
|
-
|
|
1245
|
+
// Maintenance re-applies the controller's own configured agent id and base URL. A different
|
|
1246
|
+
// value found in managed MCP config is replaced unless the owner allowlisted it, and the
|
|
1247
|
+
// divergence is reported. It never edits the owner's Hermes config.
|
|
1248
|
+
const plan = buildPlan(detection, {
|
|
1249
|
+
mode: managedMode,
|
|
1250
|
+
agentId: options.agentId,
|
|
1251
|
+
baseUrl: options.baseUrl,
|
|
1252
|
+
client: options.client,
|
|
1253
|
+
maintenance: true,
|
|
1254
|
+
});
|
|
994
1255
|
const changes = applyPlan(plan, { yes: true, dryRun: false, doctor: false });
|
|
995
1256
|
const repaired = changes.filter((change) => change.applied).map((change) => change.label);
|
|
996
1257
|
const remaining = changes.filter((change) => change.changed && !change.applied).map((change) => change.label);
|
|
1258
|
+
const identityDivergence = changes.flatMap((change) => (change.identity_divergence || [])
|
|
1259
|
+
.map((divergence) => ({ ...divergence, path: change.path })));
|
|
997
1260
|
lastMaintenanceAt = Date.now();
|
|
998
1261
|
lastMaintenance = {
|
|
999
|
-
state: remaining.length > 0 ? 'attention_required' : repaired.length > 0 ? 'repaired' : 'clear',
|
|
1262
|
+
state: remaining.length > 0 || identityDivergence.length > 0 ? 'attention_required' : repaired.length > 0 ? 'repaired' : 'clear',
|
|
1000
1263
|
checked_at: new Date(lastMaintenanceAt).toISOString(),
|
|
1001
1264
|
repaired,
|
|
1002
|
-
|
|
1265
|
+
...(identityDivergence.length ? { identity_divergence: identityDivergence } : {}),
|
|
1266
|
+
exact_fix: remaining.length > 0
|
|
1267
|
+
? 'Run npx @getmarrow/install --repair in the managed project.'
|
|
1268
|
+
: identityDivergence.length > 0
|
|
1269
|
+
? `Marrow reset ${identityDivergence.map((divergence) => divergence.field).join(', ')} in managed MCP config to the controller's configured values. If the previous value was intended, add it to MARROW_ALLOWED_BASE_URLS or MARROW_ALLOWED_AGENT_IDS and rerun npx -y @getmarrow/install@latest update.`
|
|
1270
|
+
: null,
|
|
1003
1271
|
};
|
|
1004
1272
|
return lastMaintenance;
|
|
1005
1273
|
};
|
|
@@ -1017,14 +1285,23 @@ async function sidecarOnly(parsed) {
|
|
|
1017
1285
|
}
|
|
1018
1286
|
|
|
1019
1287
|
async function controllerOnly(parsed) {
|
|
1020
|
-
|
|
1288
|
+
// The controller CLI uses the same project root and local identity as install and update, so
|
|
1289
|
+
// `controller ensure|stop|status` act on the controller those commands started. The local
|
|
1290
|
+
// identity is never sent to Marrow.
|
|
1291
|
+
const detection = detectEnvironment(process.cwd(), process.env);
|
|
1292
|
+
const envClient = process.env.MARROW_CLIENT || process.env.MARROW_HARNESS || process.env.MARROW_AGENT_CLIENT;
|
|
1293
|
+
const client = parsed.options.clientExplicit || envClient ? parsed.options.client : detectedClient(detection);
|
|
1294
|
+
const identityAgentId = localControllerAgentId(detection.root, client, parsed.options.agentId);
|
|
1295
|
+
const options = { ...parsed.options, client, identityAgentId, root: detection.root, mode: 'auto' };
|
|
1021
1296
|
let result;
|
|
1022
|
-
if (parsed.action === 'ensure') result = await
|
|
1297
|
+
if (parsed.action === 'ensure') result = await ensureCurrentGovernanceController(options);
|
|
1023
1298
|
else if (parsed.action === 'start') result = await startGovernanceController(options);
|
|
1024
|
-
else if (parsed.action === 'stop') result = await
|
|
1299
|
+
else if (parsed.action === 'stop') result = await stopProjectControllers(options);
|
|
1025
1300
|
else result = await controllerStatus(options);
|
|
1026
1301
|
if (!options.json) {
|
|
1027
1302
|
process.stdout.write(`Marrow controller: ${result.active ? 'active' : result.state}.\n`);
|
|
1303
|
+
if (result.installer_version) process.stdout.write(`Installer version: ${result.installer_version}\n`);
|
|
1304
|
+
if (result.restarted) process.stdout.write(`Restarted: ${result.restarted.from_versions.join(', ')} -> ${result.restarted.to_version}\n`);
|
|
1028
1305
|
if (result.started_at) process.stdout.write(`Started: ${result.started_at}\n`);
|
|
1029
1306
|
if (result.exact_fix) process.stdout.write(`Next: ${result.exact_fix}\n`);
|
|
1030
1307
|
}
|
|
@@ -1047,8 +1324,42 @@ async function gateOnly(parsed) {
|
|
|
1047
1324
|
}
|
|
1048
1325
|
const runtime = await preflightRuntime(options, action, type, action);
|
|
1049
1326
|
const decision = gateDecision(runtime);
|
|
1327
|
+
if (!decision.recognized) {
|
|
1328
|
+
return {
|
|
1329
|
+
ok: false,
|
|
1330
|
+
allowed: false,
|
|
1331
|
+
blocked: true,
|
|
1332
|
+
exitCode: 13,
|
|
1333
|
+
action,
|
|
1334
|
+
type,
|
|
1335
|
+
decision,
|
|
1336
|
+
message: 'Marrow runtime returned no gate decision. Run npx -y @getmarrow/install@latest update, then retry.',
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1050
1339
|
printGate(decision, runtime);
|
|
1051
|
-
|
|
1340
|
+
if (decision.runtimeDecisionId) {
|
|
1341
|
+
const receipt = decision.receiptId ? ` --gate-receipt ${shellQuote(decision.receiptId)}` : '';
|
|
1342
|
+
process.stdout.write(`Decision: ${decision.runtimeDecisionId}. After the action, record its outcome: npx @getmarrow/install proof --session ${shellQuote(options.sessionId)} --decision-id ${shellQuote(decision.runtimeDecisionId)}${receipt} --success|--failure --summary "<what happened>"\n`);
|
|
1343
|
+
}
|
|
1344
|
+
// A gate used as `gate ... && deploy` must fail the shell chain when Marrow blocks.
|
|
1345
|
+
const blocked = shouldBlock(decision, options);
|
|
1346
|
+
return {
|
|
1347
|
+
ok: !blocked,
|
|
1348
|
+
allowed: !blocked,
|
|
1349
|
+
blocked,
|
|
1350
|
+
exitCode: blocked ? 12 : 0,
|
|
1351
|
+
action,
|
|
1352
|
+
type,
|
|
1353
|
+
decision,
|
|
1354
|
+
decision_id: decision.runtimeDecisionId || null,
|
|
1355
|
+
gate_receipt_id: decision.receiptId || null,
|
|
1356
|
+
session_id: options.sessionId,
|
|
1357
|
+
...(blocked ? {
|
|
1358
|
+
message: decision.observationOnly
|
|
1359
|
+
? blockMessage(decision)
|
|
1360
|
+
: decision.exactNextAction || `Marrow gate ${decision.decision}: this action needs owner approval or a policy change before it runs.`,
|
|
1361
|
+
} : {}),
|
|
1362
|
+
};
|
|
1052
1363
|
}
|
|
1053
1364
|
|
|
1054
1365
|
async function proofOnly(parsed) {
|
|
@@ -1066,9 +1377,22 @@ async function proofOnly(parsed) {
|
|
|
1066
1377
|
options.success,
|
|
1067
1378
|
options.outcome || options.summary || (options.success ? 'Manual proof closeout succeeded.' : 'Manual proof closeout failed.'),
|
|
1068
1379
|
proof,
|
|
1069
|
-
'',
|
|
1380
|
+
options.gateReceiptId || '',
|
|
1381
|
+
null,
|
|
1382
|
+
{ perInvocation: false },
|
|
1070
1383
|
);
|
|
1071
|
-
|
|
1384
|
+
// Only committed:true is trusted closure; an accepted observation is reported, not claimed.
|
|
1385
|
+
const committed = result?.committed === true;
|
|
1386
|
+
return {
|
|
1387
|
+
ok: committed,
|
|
1388
|
+
decision_id: options.decisionId,
|
|
1389
|
+
committed,
|
|
1390
|
+
exitCode: committed ? 0 : 1,
|
|
1391
|
+
result,
|
|
1392
|
+
...(committed ? {} : {
|
|
1393
|
+
message: `Marrow did not commit this outcome as trusted closure (committed:${String(result?.committed ?? 'missing')}). Runtime-created decisions need --gate-receipt and the --session printed by gate.`,
|
|
1394
|
+
}),
|
|
1395
|
+
};
|
|
1072
1396
|
}
|
|
1073
1397
|
|
|
1074
1398
|
async function statusOnly(parsed) {
|
|
@@ -1441,7 +1765,7 @@ function fleetPanel(snapshot) {
|
|
|
1441
1765
|
return [
|
|
1442
1766
|
'Marrow Fleet Operator',
|
|
1443
1767
|
'',
|
|
1444
|
-
`Agent: ${snapshot.agent_id}`,
|
|
1768
|
+
`Agent: ${snapshot.agent_id || 'resolved by API key'}`,
|
|
1445
1769
|
`Snapshot: ${snapshot.generated_at}`,
|
|
1446
1770
|
'',
|
|
1447
1771
|
`Live agents: ${snapshot.live_agents ?? 'unknown'}`,
|
|
@@ -1472,17 +1796,19 @@ function fleetPanel(snapshot) {
|
|
|
1472
1796
|
].filter(Boolean).join('\n');
|
|
1473
1797
|
}
|
|
1474
1798
|
|
|
1475
|
-
|
|
1799
|
+
// Governed commands never name an invented agent id. MARROW_AGENT_ID, when set, is a registered
|
|
1800
|
+
// agent; otherwise Marrow resolves the key's bound agent or the plan seat.
|
|
1801
|
+
const GENERIC_GOVERNED_COMMAND = 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy warn -- <harness-command>';
|
|
1476
1802
|
|
|
1477
1803
|
function localSupportedHarnesses() {
|
|
1478
1804
|
const harnesses = [
|
|
1479
|
-
{ display_name: 'OpenAI Codex', client_label: 'codex', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --
|
|
1805
|
+
{ display_name: 'OpenAI Codex', client_label: 'codex', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run -- codex' },
|
|
1480
1806
|
{ display_name: 'Claude Code', client_label: 'claude-code', category: 'agent_harness', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
|
|
1481
1807
|
{ display_name: 'Cursor', client_label: 'cursor', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
|
|
1482
1808
|
{ display_name: 'Cursor Composer', client_label: 'composer', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
|
|
1483
1809
|
{ display_name: 'Windsurf', client_label: 'windsurf', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
|
|
1484
1810
|
{ display_name: 'Cline', client_label: 'cline', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
|
|
1485
|
-
{ display_name: 'OpenCode', client_label: 'opencode', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --
|
|
1811
|
+
{ display_name: 'OpenCode', client_label: 'opencode', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run -- opencode' },
|
|
1486
1812
|
{ display_name: 'Hermes Agent', client_label: 'hermes', category: 'agent_harness', support_level: 'first_class_addon', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install hermes' },
|
|
1487
1813
|
{ display_name: 'OpenClaw', client_label: 'openclaw', category: 'agent_harness', support_level: 'first_class_addon', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install openclaw' },
|
|
1488
1814
|
{ display_name: 'Gemini CLI', client_label: 'gemini', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
|
|
@@ -1493,7 +1819,7 @@ function localSupportedHarnesses() {
|
|
|
1493
1819
|
{ display_name: 'MiniMax', client_label: 'minimax', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
|
|
1494
1820
|
{ display_name: 'GLM', client_label: 'glm', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
|
|
1495
1821
|
{ display_name: 'MCP-compatible clients', client_label: 'mcp', category: 'mcp_client', support_level: 'native_mcp_or_sdk', install_command: `MARROW_API_KEY=mrw_live_xxx ${MCP_SETUP_COMMAND}` },
|
|
1496
|
-
{ display_name: 'CI scripts and deploy runners', client_label: 'ci', category: 'ci_runner', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --
|
|
1822
|
+
{ display_name: 'CI scripts and deploy runners', client_label: 'ci', category: 'ci_runner', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy enforce -- <ci-or-deploy-command>' },
|
|
1497
1823
|
{ display_name: 'Custom shell/API harness', client_label: 'custom', category: 'custom_runner', support_level: 'event_contract', install_command: 'POST /v1/agent/integrations/events with harness, event_type, agent_id, and action' },
|
|
1498
1824
|
];
|
|
1499
1825
|
return harnesses.map((harness) => {
|
|
@@ -1606,8 +1932,9 @@ function localIntegrationManifest(name) {
|
|
|
1606
1932
|
title: 'Marrow + Hermes Agent',
|
|
1607
1933
|
client_label: 'hermes',
|
|
1608
1934
|
command: 'hermes',
|
|
1609
|
-
install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install
|
|
1610
|
-
governed_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --
|
|
1935
|
+
install_command: 'MARROW_API_KEY=mrw_live_xxx npx -y @getmarrow/install@latest update',
|
|
1936
|
+
governed_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy enforce -- hermes',
|
|
1937
|
+
agent_identity: 'Leave MARROW_AGENT_ID unset: Marrow uses the API key\'s bound agent or the Free plan seat. On paid plans, register the agent with POST /v1/agents or bind the key to exactly one agent before setting MARROW_AGENT_ID.',
|
|
1611
1938
|
capture_points: [
|
|
1612
1939
|
'/goal -> Marrow completion contract',
|
|
1613
1940
|
'verification evidence -> Marrow proof pack',
|
|
@@ -1615,7 +1942,7 @@ function localIntegrationManifest(name) {
|
|
|
1615
1942
|
'/journey -> governance timeline',
|
|
1616
1943
|
'background subagents -> agent_id + source_meta.client=hermes',
|
|
1617
1944
|
],
|
|
1618
|
-
exact_next_action: 'Run the
|
|
1945
|
+
exact_next_action: 'Run the one-line update: it adds the pinned Marrow MCP server to ~/.hermes/config.yaml and runs the self-test. Restart Hermes, then call marrow_agent_runtime before deploy, merge, publish, migration, credential, or customer-facing work.',
|
|
1619
1946
|
};
|
|
1620
1947
|
}
|
|
1621
1948
|
if (key === 'openclaw' || key === 'open-claw') {
|
|
@@ -1625,7 +1952,7 @@ function localIntegrationManifest(name) {
|
|
|
1625
1952
|
client_label: 'openclaw',
|
|
1626
1953
|
command: 'openclaw agent',
|
|
1627
1954
|
install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install openclaw',
|
|
1628
|
-
governed_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --
|
|
1955
|
+
governed_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --profile production --policy enforce -- openclaw agent',
|
|
1629
1956
|
capture_points: [
|
|
1630
1957
|
'agent sessions -> workflow sessions',
|
|
1631
1958
|
'handoff/result files -> proof packs',
|
|
@@ -1747,7 +2074,7 @@ async function integrationsOnly(parsed) {
|
|
|
1747
2074
|
function governPanel(options) {
|
|
1748
2075
|
const rows = detectHarnesses();
|
|
1749
2076
|
const project = detectProjectSignals();
|
|
1750
|
-
const agentId = displayText(options.agentId, 80);
|
|
2077
|
+
const agentId = displayText(options.agentId, 80) || 'resolved by API key';
|
|
1751
2078
|
const profile = displayText(options.profile, 80);
|
|
1752
2079
|
const policy = displayText(options.policy, 24);
|
|
1753
2080
|
const lines = [
|
|
@@ -1770,8 +2097,8 @@ function governPanel(options) {
|
|
|
1770
2097
|
: ' recommendation: export MARROW_API_KEY to get an account/fleet-backed mode recommendation.',
|
|
1771
2098
|
'',
|
|
1772
2099
|
'Recommended first commands:',
|
|
1773
|
-
` npx @getmarrow/install run
|
|
1774
|
-
` npx @getmarrow/install run --
|
|
2100
|
+
` npx @getmarrow/install run ${options.agentId ? `--agent ${shellQuoteDisplay(options.agentId)} ` : ''}--profile production --policy enforce -- codex`,
|
|
2101
|
+
` npx @getmarrow/install run --type deploy --policy enforce -- wrangler deploy`,
|
|
1775
2102
|
` npx @getmarrow/install gate "deploy production worker after tests pass"`,
|
|
1776
2103
|
'',
|
|
1777
2104
|
'Protected by default: deploy, merge, publish, migrations, secrets, keys, production actions.',
|
|
@@ -1817,7 +2144,7 @@ function commandForSelection(state, options) {
|
|
|
1817
2144
|
}
|
|
1818
2145
|
const command = harness.command === '<your-agent-command>' ? '<your-command>' : harness.command;
|
|
1819
2146
|
const renderedCommand = command.split(/\s+/).filter(Boolean).map(shellQuote).join(' ');
|
|
1820
|
-
return `MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run
|
|
2147
|
+
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
2148
|
}
|
|
1822
2149
|
|
|
1823
2150
|
function buildGovernState(options, cwd = process.cwd()) {
|
|
@@ -1910,7 +2237,7 @@ function renderGovernTui(state, options) {
|
|
|
1910
2237
|
'| Passive agent governance for day-one use |',
|
|
1911
2238
|
'+------------------------------------------------------------+',
|
|
1912
2239
|
'',
|
|
1913
|
-
`Agent: ${displayText(options.agentId, 36)} Profile: ${displayText(options.profile, 24)} API key: ${options.apiKey ? 'present' : 'missing'}`,
|
|
2240
|
+
`Agent: ${displayText(options.agentId, 36) || 'resolved by API key'} Profile: ${displayText(options.profile, 24)} API key: ${options.apiKey ? 'present' : 'missing'}`,
|
|
1914
2241
|
`Project: ${displayText(state.project?.name || 'workspace', 36)} Signals: ${displayText((state.project?.signals || []).slice(0, 4).join(', ') || 'none', 52)}`,
|
|
1915
2242
|
'',
|
|
1916
2243
|
'Navigation: Up/Down move Left/Right change Enter select',
|
|
@@ -2036,7 +2363,7 @@ function renderFleetTui(state) {
|
|
|
2036
2363
|
'| Live fleet health, proof debt, gates, and exact fixes |',
|
|
2037
2364
|
'+------------------------------------------------------------+',
|
|
2038
2365
|
'',
|
|
2039
|
-
`Agent: ${displayText(snapshot.agent_id, 36)} Snapshot: ${displayText(snapshot.generated_at, 36)}`,
|
|
2366
|
+
`Agent: ${displayText(snapshot.agent_id, 36) || 'resolved by API key'} Snapshot: ${displayText(snapshot.generated_at, 36)}`,
|
|
2040
2367
|
`API: ${displayText(snapshot.base_url, 60)}`,
|
|
2041
2368
|
'',
|
|
2042
2369
|
'Navigation: Up/Down move Left/Right select agent Enter inspect/print',
|
|
@@ -2381,12 +2708,15 @@ async function runCli(argv) {
|
|
|
2381
2708
|
result = await integrationsOnly(parsed);
|
|
2382
2709
|
}
|
|
2383
2710
|
|
|
2711
|
+
const exitCodeCommands = ['run', 'verify-permit', 'gate', 'permit', 'proof'];
|
|
2384
2712
|
if (parsed.options?.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
2385
2713
|
else if (result?.blocked) process.stderr.write(`BLOCKED: ${result.message || 'Marrow blocked this action.'}\n`);
|
|
2714
|
+
else if (result?.ok === false && result?.message && exitCodeCommands.includes(parsed.command)) process.stderr.write(`Marrow: ${result.message}\n`);
|
|
2386
2715
|
else if (parsed.command === 'status') process.stdout.write(`${statusPanel(result)}\n`);
|
|
2716
|
+
else if (result?.advisory && result?.message) process.stdout.write(`${result.message}\n`);
|
|
2387
2717
|
else if (!['run', 'fleet', 'hermes', 'openclaw', 'integrations'].includes(parsed.command)) process.stdout.write('Marrow command completed.\n');
|
|
2388
2718
|
|
|
2389
|
-
if (
|
|
2719
|
+
if (exitCodeCommands.includes(parsed.command) || result?.blocked) {
|
|
2390
2720
|
process.exitCode = result?.exitCode ?? (result?.ok === false ? 1 : 0);
|
|
2391
2721
|
}
|
|
2392
2722
|
}
|
|
@@ -2419,6 +2749,7 @@ module.exports = {
|
|
|
2419
2749
|
verifyPermitOnly,
|
|
2420
2750
|
coverageOnly,
|
|
2421
2751
|
sidecarOnly,
|
|
2752
|
+
requestJson,
|
|
2422
2753
|
controllerOnly,
|
|
2423
2754
|
actionBinding,
|
|
2424
2755
|
gateOnly,
|