@getmarrow/install 0.1.65 → 0.1.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -22
- package/package.json +1 -1
- package/src/controller-manager.js +152 -6
- package/src/enforcement-client.js +6 -3
- package/src/first-hour.js +17 -3
- package/src/governance-sidecar.js +6 -0
- package/src/governed-runner.js +413 -90
- package/src/hermes-config.js +568 -0
- package/src/installer.js +767 -376
- 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
|
|
@@ -370,6 +374,10 @@ function parseBaseOptions(argv, startIndex = 0) {
|
|
|
370
374
|
permit: process.env.MARROW_ACTION_PERMIT || '',
|
|
371
375
|
target: process.env.MARROW_ACTION_TARGET || '',
|
|
372
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'),
|
|
373
381
|
};
|
|
374
382
|
let i = startIndex;
|
|
375
383
|
for (; i < argv.length; i += 1) {
|
|
@@ -392,7 +400,10 @@ function parseBaseOptions(argv, startIndex = 0) {
|
|
|
392
400
|
else if (arg === '--target') options.target = argv[++i] || options.target;
|
|
393
401
|
else if (arg === '--sidecar-port') options.sidecarPort = argv[++i] || options.sidecarPort;
|
|
394
402
|
else if (arg === '--proof-file') options.proofFile = argv[++i] || options.proofFile;
|
|
395
|
-
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
|
+
}
|
|
396
407
|
else if (arg === '--base-url') options.baseUrl = argv[++i] || options.baseUrl;
|
|
397
408
|
else if (arg === '--key') {
|
|
398
409
|
options.apiKey = argv[++i] || options.apiKey;
|
|
@@ -418,7 +429,10 @@ function parseArgs(argv) {
|
|
|
418
429
|
if (command === 'run') {
|
|
419
430
|
const parsed = parseBaseOptions(argv, 1);
|
|
420
431
|
const separator = argv[parsed.index] === '--' ? parsed.index + 1 : parsed.index;
|
|
421
|
-
|
|
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);
|
|
422
436
|
if (parsed.options.help) return { command: 'help' };
|
|
423
437
|
if (childCommand.length === 0) throw new Error('marrow run requires a command after --');
|
|
424
438
|
return { command, options: parsed.options, childCommand };
|
|
@@ -444,18 +458,24 @@ function parseArgs(argv) {
|
|
|
444
458
|
}
|
|
445
459
|
|
|
446
460
|
if (command === 'proof') {
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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) {
|
|
450
466
|
const arg = argv[i];
|
|
451
|
-
if (arg === '--decision-id')
|
|
452
|
-
else if (arg === '--
|
|
453
|
-
else if (arg === '--
|
|
454
|
-
else if (arg === '--
|
|
455
|
-
else if (arg === '--
|
|
456
|
-
else if (arg === '--
|
|
457
|
-
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);
|
|
458
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 };
|
|
459
479
|
if (!options.decisionId) throw new Error('marrow proof requires --decision-id');
|
|
460
480
|
return { command, options };
|
|
461
481
|
}
|
|
@@ -516,7 +536,7 @@ function dataOf(json) {
|
|
|
516
536
|
return json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : json;
|
|
517
537
|
}
|
|
518
538
|
|
|
519
|
-
async function
|
|
539
|
+
async function rawRequest(options, method, route, body, extraHeaders = {}, { timeoutMs } = {}) {
|
|
520
540
|
if (!options.apiKey) throw new Error('MARROW_API_KEY is required. Use --fail-open only for non-production local commands.');
|
|
521
541
|
const response = await fetch(new URL(route, options.baseUrl.replace(/\/$/, '/')), {
|
|
522
542
|
method,
|
|
@@ -527,15 +547,111 @@ async function requestJson(options, method, route, body, extraHeaders = {}, { ti
|
|
|
527
547
|
const text = await response.text();
|
|
528
548
|
let json = {};
|
|
529
549
|
try { json = text ? JSON.parse(text) : {}; } catch { json = { error: text.slice(0, 500) }; }
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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);
|
|
536
563
|
return dataOf(json);
|
|
537
564
|
}
|
|
538
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
|
+
|
|
539
655
|
function proofFromFile(filePath) {
|
|
540
656
|
if (!filePath) return null;
|
|
541
657
|
const raw = fs.readFileSync(path.resolve(filePath), 'utf8');
|
|
@@ -577,11 +693,14 @@ async function preflightRuntime(options, action, type, commandText) {
|
|
|
577
693
|
const surfaces = inferSurfaces(commandText || action);
|
|
578
694
|
const meta = sourceMeta(options, 'runtime', { action, command: commandText, action_type: type });
|
|
579
695
|
const project = detectProjectSignals(options.root || process.cwd());
|
|
580
|
-
|
|
696
|
+
const body = {
|
|
581
697
|
action,
|
|
582
698
|
type,
|
|
583
699
|
target,
|
|
584
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',
|
|
585
704
|
harness: sourceClient(options.client),
|
|
586
705
|
project: { ...project, harness: sourceClient(options.client) },
|
|
587
706
|
source_meta: meta,
|
|
@@ -593,7 +712,9 @@ async function preflightRuntime(options, action, type, commandText) {
|
|
|
593
712
|
governed: true,
|
|
594
713
|
source_meta: meta,
|
|
595
714
|
},
|
|
596
|
-
}
|
|
715
|
+
};
|
|
716
|
+
return durableRequestJson(options, '/v1/agent/runtime', body,
|
|
717
|
+
durableIdempotencyKey(options, '/v1/agent/runtime', [JSON.stringify(body)]));
|
|
597
718
|
}
|
|
598
719
|
|
|
599
720
|
async function recommendGovernanceMode(options, project = detectProjectSignals()) {
|
|
@@ -643,33 +764,106 @@ async function recordGovernanceModeSelection(options, state) {
|
|
|
643
764
|
});
|
|
644
765
|
}
|
|
645
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".
|
|
646
779
|
function gateDecision(runtime) {
|
|
647
|
-
const
|
|
648
|
-
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 || '');
|
|
649
803
|
return {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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),
|
|
659
829
|
};
|
|
660
830
|
}
|
|
661
831
|
|
|
662
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;
|
|
663
836
|
if (options.policy === 'audit') return false;
|
|
664
|
-
|
|
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;
|
|
665
840
|
if (options.policy === 'warn') return false;
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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('; ');
|
|
669
863
|
}
|
|
670
864
|
|
|
671
865
|
function printGate(decision, runtime, stream = process.stdout) {
|
|
672
|
-
stream.write(`Marrow gate: ${decision.decision}${decision
|
|
866
|
+
stream.write(`Marrow gate: ${decision.decision} (${gateModeText(decision)})\n`);
|
|
673
867
|
if (decision.beforeYouAct) stream.write(`Before you act: ${decision.beforeYouAct}\n`);
|
|
674
868
|
if (decision.exactNextAction) stream.write(`Next: ${decision.exactNextAction}\n`);
|
|
675
869
|
if (decision.proofPack?.required) {
|
|
@@ -741,7 +935,7 @@ function scopedExecutionEnv(permit) {
|
|
|
741
935
|
|
|
742
936
|
async function createDecision(options, action, type, target, surfaces) {
|
|
743
937
|
const meta = sourceMeta(options, 'think', { action, action_type: type });
|
|
744
|
-
|
|
938
|
+
const body = {
|
|
745
939
|
action,
|
|
746
940
|
type,
|
|
747
941
|
target,
|
|
@@ -753,10 +947,19 @@ async function createDecision(options, action, type, target, surfaces) {
|
|
|
753
947
|
governed: true,
|
|
754
948
|
source_meta: meta,
|
|
755
949
|
},
|
|
756
|
-
}
|
|
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')}`;
|
|
757
960
|
}
|
|
758
961
|
|
|
759
|
-
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 } = {}) {
|
|
760
963
|
const body = {
|
|
761
964
|
decision_id: decisionId,
|
|
762
965
|
success,
|
|
@@ -766,12 +969,14 @@ async function commitOutcome(options, decisionId, success, outcome, proof, gateR
|
|
|
766
969
|
};
|
|
767
970
|
if (gateReceiptId) body.gate_receipt_id = gateReceiptId;
|
|
768
971
|
if (modelUsage) body.model_usage = modelUsage;
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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 };
|
|
775
980
|
}
|
|
776
981
|
|
|
777
982
|
async function runGoverned(parsed, execution = {}) {
|
|
@@ -784,9 +989,11 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
784
989
|
let runtime = null;
|
|
785
990
|
let decision = null;
|
|
786
991
|
let decisionId = '';
|
|
992
|
+
let decisionSource = null;
|
|
787
993
|
let actionPermit = null;
|
|
788
994
|
let permitVerified = false;
|
|
789
995
|
let degraded = false;
|
|
996
|
+
let advisory = false;
|
|
790
997
|
let protectedAction = risky;
|
|
791
998
|
const surfaces = inferSurfaces(commandText || action);
|
|
792
999
|
|
|
@@ -806,10 +1013,15 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
806
1013
|
runtime = await preflightRuntime(options, action, type, commandText);
|
|
807
1014
|
decision = gateDecision(runtime);
|
|
808
1015
|
protectedAction = risky
|
|
809
|
-
|| decision
|
|
810
|
-
|| decision
|
|
811
|
-
|| decision
|
|
812
|
-
|
|
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
|
+
}
|
|
813
1025
|
if (shouldBlock(decision, options)) {
|
|
814
1026
|
return {
|
|
815
1027
|
ok: false,
|
|
@@ -819,22 +1031,29 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
819
1031
|
type,
|
|
820
1032
|
risky,
|
|
821
1033
|
decision,
|
|
822
|
-
message: decision
|
|
1034
|
+
message: blockMessage(decision),
|
|
823
1035
|
};
|
|
824
1036
|
}
|
|
825
1037
|
const target = options.target || commandText;
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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.');
|
|
829
1048
|
actionPermit = await issueActionPermit(requestJson, options, {
|
|
830
1049
|
action,
|
|
831
1050
|
type,
|
|
832
1051
|
target,
|
|
833
1052
|
surfaces,
|
|
834
1053
|
decisionId,
|
|
835
|
-
gateReceiptId: decision
|
|
1054
|
+
gateReceiptId: decision.receiptId,
|
|
836
1055
|
ownerApproval: options.ownerApproval,
|
|
837
|
-
proofRequirements: decision
|
|
1056
|
+
proofRequirements: decision.proofPack?.required_fields || decision.proofPack?.missing || [],
|
|
838
1057
|
});
|
|
839
1058
|
if (!actionPermit?.permit || !actionPermit?.permit_id) {
|
|
840
1059
|
throw new Error('Marrow did not issue a valid action permit.');
|
|
@@ -857,7 +1076,7 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
857
1076
|
actionPermit = null;
|
|
858
1077
|
permitVerified = false;
|
|
859
1078
|
degraded = true;
|
|
860
|
-
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`);
|
|
861
1080
|
} else {
|
|
862
1081
|
return {
|
|
863
1082
|
ok: false,
|
|
@@ -881,6 +1100,7 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
881
1100
|
: `Marrow governed command failed with exit code ${child.exitCode}.`;
|
|
882
1101
|
|
|
883
1102
|
let commit = null;
|
|
1103
|
+
let commitState = decisionId ? 'failed' : 'not_recorded';
|
|
884
1104
|
if (decisionId) {
|
|
885
1105
|
try {
|
|
886
1106
|
commit = await commitOutcome(
|
|
@@ -892,9 +1112,15 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
892
1112
|
decision?.receiptId || '',
|
|
893
1113
|
child.modelUsage,
|
|
894
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
|
+
}
|
|
895
1119
|
} catch (error) {
|
|
896
1120
|
process.stderr.write(`Marrow outcome commit failed: ${error.message}\n`);
|
|
897
1121
|
}
|
|
1122
|
+
} else if (!degraded) {
|
|
1123
|
+
process.stderr.write('Marrow outcome not recorded: no decision id was returned before the command ran.\n');
|
|
898
1124
|
}
|
|
899
1125
|
|
|
900
1126
|
let permitClosed = null;
|
|
@@ -921,9 +1147,13 @@ async function runGoverned(parsed, execution = {}) {
|
|
|
921
1147
|
type,
|
|
922
1148
|
risky,
|
|
923
1149
|
degraded,
|
|
1150
|
+
advisory,
|
|
924
1151
|
decision,
|
|
925
1152
|
decision_id: decisionId,
|
|
926
|
-
|
|
1153
|
+
decision_source: decisionSource,
|
|
1154
|
+
gate_receipt_id: decision?.receiptId || null,
|
|
1155
|
+
outcome_committed: commitState === 'committed',
|
|
1156
|
+
outcome_commit_state: commitState,
|
|
927
1157
|
permit_id: actionPermit?.permit_id || null,
|
|
928
1158
|
permit_verified: permitVerified,
|
|
929
1159
|
permit_closed: Boolean(permitClosed),
|
|
@@ -948,11 +1178,28 @@ async function permitOnly(parsed) {
|
|
|
948
1178
|
const surfaces = inferSurfaces(target);
|
|
949
1179
|
const runtime = await preflightRuntime(options, action, type, target);
|
|
950
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
|
+
}
|
|
951
1184
|
if (shouldBlock(decision, options)) {
|
|
952
|
-
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.' };
|
|
953
1202
|
}
|
|
954
|
-
const think = await createDecision(options, action, type, target, surfaces);
|
|
955
|
-
const decisionId = think.decision_id || think.id || think.decision?.id || '';
|
|
956
1203
|
const result = await issueActionPermit(requestJson, options, {
|
|
957
1204
|
action,
|
|
958
1205
|
type,
|
|
@@ -961,9 +1208,9 @@ async function permitOnly(parsed) {
|
|
|
961
1208
|
decisionId,
|
|
962
1209
|
gateReceiptId: decision.receiptId,
|
|
963
1210
|
ownerApproval: options.ownerApproval,
|
|
964
|
-
proofRequirements: decision
|
|
1211
|
+
proofRequirements: decision.proofPack?.required_fields || decision.proofPack?.missing || [],
|
|
965
1212
|
});
|
|
966
|
-
return { ok: true, decision_id: decisionId, ...result };
|
|
1213
|
+
return { ok: true, decision_id: decisionId, gate_receipt_id: decision.receiptId, ...result };
|
|
967
1214
|
}
|
|
968
1215
|
|
|
969
1216
|
async function verifyPermitOnly(parsed) {
|
|
@@ -995,16 +1242,32 @@ async function sidecarOnly(parsed) {
|
|
|
995
1242
|
const maintain = async () => {
|
|
996
1243
|
if (lastMaintenance && Date.now() - lastMaintenanceAt < 5 * 60_000) return lastMaintenance;
|
|
997
1244
|
const detection = detectEnvironment(root, process.env);
|
|
998
|
-
|
|
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
|
+
});
|
|
999
1255
|
const changes = applyPlan(plan, { yes: true, dryRun: false, doctor: false });
|
|
1000
1256
|
const repaired = changes.filter((change) => change.applied).map((change) => change.label);
|
|
1001
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 })));
|
|
1002
1260
|
lastMaintenanceAt = Date.now();
|
|
1003
1261
|
lastMaintenance = {
|
|
1004
|
-
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',
|
|
1005
1263
|
checked_at: new Date(lastMaintenanceAt).toISOString(),
|
|
1006
1264
|
repaired,
|
|
1007
|
-
|
|
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,
|
|
1008
1271
|
};
|
|
1009
1272
|
return lastMaintenance;
|
|
1010
1273
|
};
|
|
@@ -1022,16 +1285,23 @@ async function sidecarOnly(parsed) {
|
|
|
1022
1285
|
}
|
|
1023
1286
|
|
|
1024
1287
|
async function controllerOnly(parsed) {
|
|
1025
|
-
//
|
|
1026
|
-
|
|
1027
|
-
|
|
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' };
|
|
1028
1296
|
let result;
|
|
1029
|
-
if (parsed.action === 'ensure') result = await
|
|
1297
|
+
if (parsed.action === 'ensure') result = await ensureCurrentGovernanceController(options);
|
|
1030
1298
|
else if (parsed.action === 'start') result = await startGovernanceController(options);
|
|
1031
|
-
else if (parsed.action === 'stop') result = await
|
|
1299
|
+
else if (parsed.action === 'stop') result = await stopProjectControllers(options);
|
|
1032
1300
|
else result = await controllerStatus(options);
|
|
1033
1301
|
if (!options.json) {
|
|
1034
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`);
|
|
1035
1305
|
if (result.started_at) process.stdout.write(`Started: ${result.started_at}\n`);
|
|
1036
1306
|
if (result.exact_fix) process.stdout.write(`Next: ${result.exact_fix}\n`);
|
|
1037
1307
|
}
|
|
@@ -1054,8 +1324,42 @@ async function gateOnly(parsed) {
|
|
|
1054
1324
|
}
|
|
1055
1325
|
const runtime = await preflightRuntime(options, action, type, action);
|
|
1056
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
|
+
}
|
|
1057
1339
|
printGate(decision, runtime);
|
|
1058
|
-
|
|
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
|
+
};
|
|
1059
1363
|
}
|
|
1060
1364
|
|
|
1061
1365
|
async function proofOnly(parsed) {
|
|
@@ -1073,9 +1377,22 @@ async function proofOnly(parsed) {
|
|
|
1073
1377
|
options.success,
|
|
1074
1378
|
options.outcome || options.summary || (options.success ? 'Manual proof closeout succeeded.' : 'Manual proof closeout failed.'),
|
|
1075
1379
|
proof,
|
|
1076
|
-
'',
|
|
1380
|
+
options.gateReceiptId || '',
|
|
1381
|
+
null,
|
|
1382
|
+
{ perInvocation: false },
|
|
1077
1383
|
);
|
|
1078
|
-
|
|
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
|
+
};
|
|
1079
1396
|
}
|
|
1080
1397
|
|
|
1081
1398
|
async function statusOnly(parsed) {
|
|
@@ -1479,17 +1796,19 @@ function fleetPanel(snapshot) {
|
|
|
1479
1796
|
].filter(Boolean).join('\n');
|
|
1480
1797
|
}
|
|
1481
1798
|
|
|
1482
|
-
|
|
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>';
|
|
1483
1802
|
|
|
1484
1803
|
function localSupportedHarnesses() {
|
|
1485
1804
|
const harnesses = [
|
|
1486
|
-
{ 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' },
|
|
1487
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}` },
|
|
1488
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}` },
|
|
1489
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}` },
|
|
1490
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}` },
|
|
1491
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}` },
|
|
1492
|
-
{ 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' },
|
|
1493
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' },
|
|
1494
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' },
|
|
1495
1814
|
{ display_name: 'Gemini CLI', client_label: 'gemini', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
|
|
@@ -1500,7 +1819,7 @@ function localSupportedHarnesses() {
|
|
|
1500
1819
|
{ display_name: 'MiniMax', client_label: 'minimax', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
|
|
1501
1820
|
{ display_name: 'GLM', client_label: 'glm', category: 'model_cli', support_level: 'governed_runner', install_command: GENERIC_GOVERNED_COMMAND },
|
|
1502
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}` },
|
|
1503
|
-
{ 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>' },
|
|
1504
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' },
|
|
1505
1824
|
];
|
|
1506
1825
|
return harnesses.map((harness) => {
|
|
@@ -1613,8 +1932,9 @@ function localIntegrationManifest(name) {
|
|
|
1613
1932
|
title: 'Marrow + Hermes Agent',
|
|
1614
1933
|
client_label: 'hermes',
|
|
1615
1934
|
command: 'hermes',
|
|
1616
|
-
install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install
|
|
1617
|
-
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.',
|
|
1618
1938
|
capture_points: [
|
|
1619
1939
|
'/goal -> Marrow completion contract',
|
|
1620
1940
|
'verification evidence -> Marrow proof pack',
|
|
@@ -1622,7 +1942,7 @@ function localIntegrationManifest(name) {
|
|
|
1622
1942
|
'/journey -> governance timeline',
|
|
1623
1943
|
'background subagents -> agent_id + source_meta.client=hermes',
|
|
1624
1944
|
],
|
|
1625
|
-
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.',
|
|
1626
1946
|
};
|
|
1627
1947
|
}
|
|
1628
1948
|
if (key === 'openclaw' || key === 'open-claw') {
|
|
@@ -1632,7 +1952,7 @@ function localIntegrationManifest(name) {
|
|
|
1632
1952
|
client_label: 'openclaw',
|
|
1633
1953
|
command: 'openclaw agent',
|
|
1634
1954
|
install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install openclaw',
|
|
1635
|
-
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',
|
|
1636
1956
|
capture_points: [
|
|
1637
1957
|
'agent sessions -> workflow sessions',
|
|
1638
1958
|
'handoff/result files -> proof packs',
|
|
@@ -1778,7 +2098,7 @@ function governPanel(options) {
|
|
|
1778
2098
|
'',
|
|
1779
2099
|
'Recommended first commands:',
|
|
1780
2100
|
` npx @getmarrow/install run ${options.agentId ? `--agent ${shellQuoteDisplay(options.agentId)} ` : ''}--profile production --policy enforce -- codex`,
|
|
1781
|
-
` npx @getmarrow/install run --
|
|
2101
|
+
` npx @getmarrow/install run --type deploy --policy enforce -- wrangler deploy`,
|
|
1782
2102
|
` npx @getmarrow/install gate "deploy production worker after tests pass"`,
|
|
1783
2103
|
'',
|
|
1784
2104
|
'Protected by default: deploy, merge, publish, migrations, secrets, keys, production actions.',
|
|
@@ -2388,12 +2708,15 @@ async function runCli(argv) {
|
|
|
2388
2708
|
result = await integrationsOnly(parsed);
|
|
2389
2709
|
}
|
|
2390
2710
|
|
|
2711
|
+
const exitCodeCommands = ['run', 'verify-permit', 'gate', 'permit', 'proof'];
|
|
2391
2712
|
if (parsed.options?.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
2392
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`);
|
|
2393
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`);
|
|
2394
2717
|
else if (!['run', 'fleet', 'hermes', 'openclaw', 'integrations'].includes(parsed.command)) process.stdout.write('Marrow command completed.\n');
|
|
2395
2718
|
|
|
2396
|
-
if (
|
|
2719
|
+
if (exitCodeCommands.includes(parsed.command) || result?.blocked) {
|
|
2397
2720
|
process.exitCode = result?.exitCode ?? (result?.ok === false ? 1 : 0);
|
|
2398
2721
|
}
|
|
2399
2722
|
}
|