@getmarrow/install 0.1.36 → 0.1.38
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 +52 -3
- package/bin/marrow-install.js +1 -1
- package/package.json +1 -1
- package/src/controller-manager.js +449 -0
- package/src/governance-sidecar.js +33 -4
- package/src/governed-runner.js +168 -33
- package/src/installer.js +84 -17
package/README.md
CHANGED
|
@@ -74,9 +74,44 @@ npx -y @getmarrow/install@latest doctor
|
|
|
74
74
|
npx -y @getmarrow/install@latest --repair
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
`activate` reconciles Marrow-managed entries while retaining unrelated user hooks and configuration.
|
|
77
|
+
`activate` reconciles Marrow-managed entries while retaining unrelated user hooks and configuration. After that explicit activation, the local controller can restore drifted Marrow-managed hooks and configuration. Package upgrades, owner policy, credentials, and unrelated local files remain explicit and subject to the operator's normal change policy.
|
|
78
78
|
|
|
79
|
-
##
|
|
79
|
+
## Automatic Controller
|
|
80
|
+
|
|
81
|
+
On Linux, successful install, repair, and activation starts a loopback-only controller that survives individual agent sessions. It keeps the signed action-permit broker available, checks installer-managed hooks every five minutes, safely restores missing managed entries, and reports an exact fix when repair is not safe. The API key remains process-only; private controller state is owner-only and contains no Marrow credential.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx @getmarrow/install controller status
|
|
85
|
+
npx @getmarrow/install controller ensure
|
|
86
|
+
npx @getmarrow/install controller stop
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Persistent controller lifecycle is currently Linux-only. On macOS or Windows, activation still installs and verifies the supported hooks without starting or signaling a background process; run `npx @getmarrow/install sidecar` under an owner-managed service and pass `--no-controller`. The controller does not silently upgrade packages, change governance policy, rotate credentials, or modify unrelated project configuration.
|
|
90
|
+
|
|
91
|
+
## What's New in v0.1.38
|
|
92
|
+
|
|
93
|
+
v0.1.38 makes a meaningful Marrow intervention visible without adding manual work to routine agent sessions:
|
|
94
|
+
|
|
95
|
+
- generated instructions tell agents to retrieve and relay one factual intervention receipt after Marrow changes consequential work;
|
|
96
|
+
- Fleet Reports surface the same receipt for owners, including the required workflow, proof status, permit follow-through, and recorded outcome;
|
|
97
|
+
- agents remain quiet for routine low-risk work, and receipts exclude raw context, raw outcomes, proof values, credentials, and cross-tenant data.
|
|
98
|
+
|
|
99
|
+
It preserves the automatic local control lifecycle introduced in v0.1.37.
|
|
100
|
+
|
|
101
|
+
## Previous: v0.1.37
|
|
102
|
+
|
|
103
|
+
v0.1.37 adds the automatic local control lifecycle after explicit owner activation:
|
|
104
|
+
|
|
105
|
+
- a project-and-agent-scoped loopback controller survives individual agent sessions;
|
|
106
|
+
- installer-managed hooks are checked and safely restored without changing unrelated files;
|
|
107
|
+
- the governed runner automatically classifies consequential commands and requires fresh signed permits for protected actions;
|
|
108
|
+
- pre-action, execution, result, proof, and outcome receipts share stable correlation;
|
|
109
|
+
- integration coverage states exactly what is native, MCP-routed, governed-wrapper controlled, or adapter-required;
|
|
110
|
+
- in-session value messages use measured evidence only and report unavailable data instead of synthetic savings.
|
|
111
|
+
|
|
112
|
+
It preserves the update and permit controls introduced in v0.1.36.
|
|
113
|
+
|
|
114
|
+
## Previous: v0.1.36
|
|
80
115
|
|
|
81
116
|
v0.1.36 combines guided, operator-controlled client updates with a signed permit boundary for protected actions. Installer status, activation reports, and the Fleet Operator expose request-specific update advisories with exact update and verification commands while keeping local mutation explicit:
|
|
82
117
|
|
|
@@ -200,6 +235,7 @@ npx @getmarrow/install permit --agent deploy-agent --type deploy --action "deplo
|
|
|
200
235
|
MARROW_ACTION_PERMIT=... npx @getmarrow/install verify-permit --agent deploy-agent --type deploy --action "deploy production"
|
|
201
236
|
npx @getmarrow/install coverage --agent deploy-agent
|
|
202
237
|
npx @getmarrow/install sidecar --agent deploy-agent
|
|
238
|
+
npx @getmarrow/install controller status
|
|
203
239
|
npx @getmarrow/install status
|
|
204
240
|
npx @getmarrow/install doctor
|
|
205
241
|
npx @getmarrow/install --repair
|
|
@@ -225,13 +261,26 @@ The fleet view shows live agents, active workflows, agent disagreements and thei
|
|
|
225
261
|
|
|
226
262
|
These are integration surfaces for one Marrow product, not separate products.
|
|
227
263
|
|
|
264
|
+
## Exact Integration Coverage
|
|
265
|
+
|
|
266
|
+
Run `npx @getmarrow/install integrations --json` for the machine-readable matrix. The table below intentionally distinguishes full automatic interception from MCP-routed, wrapper-bounded, and adapter-required coverage.
|
|
267
|
+
|
|
268
|
+
| Harnesses | Pre-action | Result | Outcome closure | Proof enforcement | Safe repair |
|
|
269
|
+
| --- | --- | --- | --- | --- | --- |
|
|
270
|
+
| Claude Code | Automatic native hook | Automatic native hook | Correlated when determinable | Automatic for protected actions | Managed config after activation |
|
|
271
|
+
| Cursor, Composer, Cline, Windsurf | MCP-routed | MCP-routed | MCP-routed | Explicit or governed runner | Managed config after activation |
|
|
272
|
+
| Codex, OpenCode, Gemini, Grok, DeepSeek, Qwen, Kimi, MiniMax, GLM | Automatic inside governed runner | Automatic inside governed runner | Automatic when result is known | Automatic for protected actions | Managed config after activation |
|
|
273
|
+
| Hermes, OpenClaw, custom harnesses | Adapter required | Adapter required | Adapter required | Adapter required | Adapter owned |
|
|
274
|
+
|
|
275
|
+
For native hooks, a successful tool exit is not treated as a successful business outcome when proof is missing. MCP coverage includes only actions routed through that MCP client. Governed-runner coverage includes only commands launched through the runner. Event-contract integrations must emit the documented lifecycle themselves.
|
|
276
|
+
|
|
228
277
|
## Always-On Lifecycle
|
|
229
278
|
|
|
230
279
|
Supported integrations capture a compact lifecycle without storing raw prompts, completions, command output, tool output, or credentials. Marrow recognizes prompt, goal, pre-action, tool/command result, verification evidence, workflow/session, subagent, handoff, proof-pack, and outcome events.
|
|
231
280
|
|
|
232
281
|
Meaningful work opens an outcome-closure item. A tool exit or workflow completion does not silently count as a successful business outcome; an explicit outcome receipt closes it. Transient delivery failures are held in an owner-only local spool and retried with the same event ID so retries do not create duplicate lifecycle records.
|
|
233
282
|
|
|
234
|
-
Owners can inspect pending outcomes in Fleet Operations. Agents can retrieve
|
|
283
|
+
Owners can inspect pending outcomes in Fleet Operations and recent intervention receipts in Reports. Agents can retrieve the same tenant-scoped receipt through the decision trace to explain what Marrow blocked, warned about, or held for review; the required workflow; proof status; permit follow-through; and the recorded outcome. The receipt excludes raw context, raw outcomes, proof values, credentials, and other tenants' data. Agents should relay one factual receipt summary after a meaningful intervention and stay quiet for routine low-risk work.
|
|
235
284
|
|
|
236
285
|
## Passive Token and Value Proof
|
|
237
286
|
|
package/bin/marrow-install.js
CHANGED
|
@@ -4,7 +4,7 @@ const installer = require('../src/installer');
|
|
|
4
4
|
const governedRunner = require('../src/governed-runner');
|
|
5
5
|
|
|
6
6
|
const argv = process.argv.slice(2);
|
|
7
|
-
const governedCommands = new Set(['run', 'gate', 'proof', 'status', 'govern', 'fleet', 'hermes', 'openclaw', 'integrations', 'permit', 'verify-permit', 'coverage', 'sidecar']);
|
|
7
|
+
const governedCommands = new Set(['run', 'gate', 'proof', 'status', 'govern', 'fleet', 'hermes', 'openclaw', 'integrations', 'permit', 'verify-permit', 'coverage', 'sidecar', 'controller']);
|
|
8
8
|
const runCli = governedCommands.has(argv[0]) ? governedRunner.runCli : installer.runCli;
|
|
9
9
|
|
|
10
10
|
runCli(argv).catch((error) => {
|
package/package.json
CHANGED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const crypto = require('node:crypto');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { spawn } = require('node:child_process');
|
|
6
|
+
|
|
7
|
+
const START_TIMEOUT_MS = 5_000;
|
|
8
|
+
const STOP_TIMEOUT_MS = 3_000;
|
|
9
|
+
const HEALTH_TIMEOUT_MS = 1_000;
|
|
10
|
+
const MAX_STATE_BYTES = 8 * 1024;
|
|
11
|
+
const LIFECYCLE_LOCK_STALE_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
function controllerIdentity(options = {}) {
|
|
14
|
+
const root = path.resolve(options.root || process.cwd());
|
|
15
|
+
const agentId = String(options.agentId || process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || 'agent').trim() || 'agent';
|
|
16
|
+
return crypto.createHash('sha256').update(`${root}\0${agentId}`).digest('hex').slice(0, 24);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function controllerDirectory(options = {}) {
|
|
20
|
+
return process.env.MARROW_SIDECAR_STATE_DIR
|
|
21
|
+
|| path.join(os.homedir(), '.marrow', 'controllers', controllerIdentity(options));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stateFile(options = {}) {
|
|
25
|
+
return path.join(controllerDirectory(options), 'active.json');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function lifecycleLockFile(options = {}) {
|
|
29
|
+
return path.join(controllerDirectory(options), 'lifecycle.lock');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function currentUid() {
|
|
33
|
+
return typeof process.getuid === 'function' ? process.getuid() : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertPrivatePath(filePath, kind) {
|
|
37
|
+
const stat = fs.lstatSync(filePath);
|
|
38
|
+
const uid = currentUid();
|
|
39
|
+
if (stat.isSymbolicLink()) throw new Error(`Controller ${kind} cannot be a symlink.`);
|
|
40
|
+
if (kind === 'directory' ? !stat.isDirectory() : !stat.isFile()) {
|
|
41
|
+
throw new Error(`Controller ${kind} has an invalid file type.`);
|
|
42
|
+
}
|
|
43
|
+
if (uid !== null && stat.uid !== uid) throw new Error(`Controller ${kind} must be owned by the current user.`);
|
|
44
|
+
const forbidden = kind === 'directory' ? 0o077 : 0o077;
|
|
45
|
+
if ((stat.mode & forbidden) !== 0) throw new Error(`Controller ${kind} permissions are too broad.`);
|
|
46
|
+
return stat;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensurePrivateDirectory(options = {}) {
|
|
50
|
+
const directory = path.resolve(controllerDirectory(options));
|
|
51
|
+
const parsed = path.parse(directory);
|
|
52
|
+
let current = parsed.root;
|
|
53
|
+
for (const segment of directory.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
|
54
|
+
current = path.join(current, segment);
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(current, { mode: 0o700 });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
59
|
+
}
|
|
60
|
+
const stat = fs.lstatSync(current);
|
|
61
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(current) !== current) {
|
|
62
|
+
throw new Error('Controller state directory cannot contain symlinked path components.');
|
|
63
|
+
}
|
|
64
|
+
if ((stat.mode & 0o022) !== 0 && (stat.mode & 0o1000) === 0) {
|
|
65
|
+
throw new Error('Controller state directory cannot be nested under a non-sticky writable ancestor.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
fs.chmodSync(directory, 0o700);
|
|
69
|
+
assertPrivatePath(directory, 'directory');
|
|
70
|
+
return directory;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readState(options = {}) {
|
|
74
|
+
const filePath = stateFile(options);
|
|
75
|
+
if (!fs.existsSync(filePath)) return null;
|
|
76
|
+
const stat = assertPrivatePath(filePath, 'state file');
|
|
77
|
+
if (stat.size > MAX_STATE_BYTES) throw new Error('Controller state file is oversized.');
|
|
78
|
+
const value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
79
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Controller state is invalid.');
|
|
80
|
+
if (!Number.isInteger(value.pid) || value.pid <= 1) throw new Error('Controller state PID is invalid.');
|
|
81
|
+
if (!Number.isInteger(value.port) || value.port < 1 || value.port > 65535) throw new Error('Controller state port is invalid.');
|
|
82
|
+
if (value.host !== '127.0.0.1') throw new Error('Controller state is not loopback-bound.');
|
|
83
|
+
if (typeof value.token !== 'string' || !/^[a-f0-9]{64}$/.test(value.token)) throw new Error('Controller state token is invalid.');
|
|
84
|
+
if (typeof value.instance_id !== 'string' || !/^sidecar-[0-9a-f-]{36}$/.test(value.instance_id)) throw new Error('Controller instance identity is invalid.');
|
|
85
|
+
if (!Number.isFinite(Date.parse(value.started_at))) throw new Error('Controller start timestamp is invalid.');
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function pidAlive(pid) {
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function controllerSupportedPlatform(platform = process.platform) {
|
|
99
|
+
return platform === 'linux';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isExpectedControllerProcess(pid, platform = process.platform) {
|
|
103
|
+
if (!controllerSupportedPlatform(platform) || !Number.isInteger(pid) || pid <= 1) return false;
|
|
104
|
+
try {
|
|
105
|
+
const args = fs.readFileSync(`/proc/${pid}/cmdline`).toString('utf8').split('\0').filter(Boolean);
|
|
106
|
+
if (args.length < 3 || args[2] !== 'sidecar') return false;
|
|
107
|
+
return fs.realpathSync(args[1]) === fs.realpathSync(require.resolve('../bin/marrow-install.js'));
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function controllerStatus(options = {}) {
|
|
114
|
+
if (!controllerSupportedPlatform(options.platform)) {
|
|
115
|
+
return {
|
|
116
|
+
active: false,
|
|
117
|
+
state: 'unsupported_platform',
|
|
118
|
+
started_at: null,
|
|
119
|
+
instance_id: null,
|
|
120
|
+
exact_fix: 'Persistent controller lifecycle is currently supported on Linux. Use --no-controller and run npx @getmarrow/install sidecar under an owner-managed service.',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
let state;
|
|
124
|
+
try {
|
|
125
|
+
state = readState(options);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return {
|
|
128
|
+
active: false,
|
|
129
|
+
state: 'invalid_state',
|
|
130
|
+
started_at: null,
|
|
131
|
+
instance_id: null,
|
|
132
|
+
exact_fix: 'Run npx @getmarrow/install controller stop, then npx @getmarrow/install controller ensure.',
|
|
133
|
+
error: error instanceof Error ? error.message : 'Controller state is invalid.',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (!state) {
|
|
137
|
+
return {
|
|
138
|
+
active: false,
|
|
139
|
+
state: 'stopped',
|
|
140
|
+
started_at: null,
|
|
141
|
+
instance_id: null,
|
|
142
|
+
exact_fix: 'Run npx @getmarrow/install controller ensure.',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (!pidAlive(state.pid)) {
|
|
146
|
+
return {
|
|
147
|
+
active: false,
|
|
148
|
+
state: 'stale',
|
|
149
|
+
started_at: state.started_at,
|
|
150
|
+
instance_id: state.instance_id,
|
|
151
|
+
exact_fix: 'Run npx @getmarrow/install controller ensure.',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (!isExpectedControllerProcess(state.pid, options.platform)) {
|
|
155
|
+
return {
|
|
156
|
+
active: false,
|
|
157
|
+
state: 'identity_mismatch',
|
|
158
|
+
started_at: state.started_at,
|
|
159
|
+
instance_id: state.instance_id,
|
|
160
|
+
exact_fix: 'Inspect the recorded process and controller state before retrying. Marrow will not signal an unverified PID.',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
165
|
+
timeout.unref?.();
|
|
166
|
+
try {
|
|
167
|
+
const response = await fetch(`http://127.0.0.1:${state.port}/health`, {
|
|
168
|
+
headers: { Authorization: `Bearer ${state.token}` },
|
|
169
|
+
signal: controller.signal,
|
|
170
|
+
});
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
return {
|
|
173
|
+
active: false,
|
|
174
|
+
state: 'identity_mismatch',
|
|
175
|
+
started_at: state.started_at,
|
|
176
|
+
instance_id: state.instance_id,
|
|
177
|
+
exact_fix: 'Inspect the authenticated controller endpoint and private state before retrying. Marrow will not signal either process.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
let body;
|
|
181
|
+
try {
|
|
182
|
+
body = await response.json();
|
|
183
|
+
} catch {
|
|
184
|
+
return {
|
|
185
|
+
active: false,
|
|
186
|
+
state: 'identity_mismatch',
|
|
187
|
+
started_at: state.started_at,
|
|
188
|
+
instance_id: state.instance_id,
|
|
189
|
+
exact_fix: 'Inspect the authenticated controller endpoint and private state before retrying. Marrow will not signal either process.',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (body?.ok !== true || body?.instance_id !== state.instance_id || body?.pid !== state.pid) {
|
|
193
|
+
return {
|
|
194
|
+
active: false,
|
|
195
|
+
state: 'identity_mismatch',
|
|
196
|
+
started_at: state.started_at,
|
|
197
|
+
instance_id: state.instance_id,
|
|
198
|
+
exact_fix: 'Inspect the authenticated controller endpoint and private state before retrying. Marrow will not signal either process.',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const maintenance = body.maintenance && typeof body.maintenance === 'object'
|
|
202
|
+
? body.maintenance
|
|
203
|
+
: null;
|
|
204
|
+
return {
|
|
205
|
+
active: true,
|
|
206
|
+
state: 'active',
|
|
207
|
+
started_at: state.started_at,
|
|
208
|
+
instance_id: state.instance_id,
|
|
209
|
+
maintenance,
|
|
210
|
+
exact_fix: maintenance?.exact_fix || null,
|
|
211
|
+
};
|
|
212
|
+
} catch {
|
|
213
|
+
return {
|
|
214
|
+
active: false,
|
|
215
|
+
state: 'unreachable',
|
|
216
|
+
started_at: state.started_at,
|
|
217
|
+
instance_id: state.instance_id,
|
|
218
|
+
exact_fix: 'Run npx @getmarrow/install controller stop, then npx @getmarrow/install controller ensure.',
|
|
219
|
+
};
|
|
220
|
+
} finally {
|
|
221
|
+
clearTimeout(timeout);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function cleanControllerEnv(options) {
|
|
226
|
+
const keep = [
|
|
227
|
+
'HOME', 'USER', 'LOGNAME', 'PATH', 'LANG', 'LC_ALL', 'TMPDIR', 'TEMP', 'TMP',
|
|
228
|
+
'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR',
|
|
229
|
+
];
|
|
230
|
+
const env = {};
|
|
231
|
+
for (const name of keep) {
|
|
232
|
+
if (process.env[name]) env[name] = process.env[name];
|
|
233
|
+
}
|
|
234
|
+
env.MARROW_API_KEY = options.apiKey;
|
|
235
|
+
env.MARROW_BASE_URL = options.baseUrl;
|
|
236
|
+
env.MARROW_FLEET_AGENT_ID = options.agentId;
|
|
237
|
+
env.MARROW_AGENT_ID = options.agentId;
|
|
238
|
+
env.MARROW_CLIENT = options.client;
|
|
239
|
+
env.MARROW_GOVERN_PROFILE = options.profile;
|
|
240
|
+
env.MARROW_GOVERN_POLICY = options.policy;
|
|
241
|
+
env.MARROW_CONTROLLER_PROJECT_ROOT = path.resolve(options.root || process.cwd());
|
|
242
|
+
env.MARROW_CONTROLLER_MANAGED_MODE = options.mode || 'auto';
|
|
243
|
+
env.MARROW_SIDECAR_STATE_DIR = controllerDirectory(options);
|
|
244
|
+
return env;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function waitFor(predicate, timeoutMs) {
|
|
248
|
+
const deadline = Date.now() + timeoutMs;
|
|
249
|
+
do {
|
|
250
|
+
const result = await predicate();
|
|
251
|
+
if (result) return result;
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
253
|
+
} while (Date.now() < deadline);
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function tryAcquireLifecycleLock(options = {}) {
|
|
258
|
+
const filePath = lifecycleLockFile(options);
|
|
259
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
260
|
+
let descriptor;
|
|
261
|
+
let created = false;
|
|
262
|
+
try {
|
|
263
|
+
descriptor = fs.openSync(filePath, 'wx', 0o600);
|
|
264
|
+
created = true;
|
|
265
|
+
fs.writeFileSync(descriptor, JSON.stringify({
|
|
266
|
+
pid: process.pid,
|
|
267
|
+
nonce,
|
|
268
|
+
created_at: new Date().toISOString(),
|
|
269
|
+
}) + '\n', 'utf8');
|
|
270
|
+
fs.fsyncSync(descriptor);
|
|
271
|
+
fs.chmodSync(filePath, 0o600);
|
|
272
|
+
return { descriptor, filePath, nonce };
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (descriptor !== undefined) {
|
|
275
|
+
try { fs.closeSync(descriptor); } catch {}
|
|
276
|
+
}
|
|
277
|
+
if (created) {
|
|
278
|
+
try { fs.unlinkSync(filePath); } catch {}
|
|
279
|
+
}
|
|
280
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
281
|
+
const stat = assertPrivatePath(filePath, 'lifecycle lock');
|
|
282
|
+
if (stat.size > MAX_STATE_BYTES) throw new Error('Controller lifecycle lock is oversized.');
|
|
283
|
+
let value;
|
|
284
|
+
try {
|
|
285
|
+
value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
286
|
+
} catch {
|
|
287
|
+
throw new Error('Controller lifecycle lock is invalid.');
|
|
288
|
+
}
|
|
289
|
+
const stale = Date.now() - stat.mtimeMs > LIFECYCLE_LOCK_STALE_MS;
|
|
290
|
+
if (stale && Number.isInteger(value?.pid) && !pidAlive(value.pid)) {
|
|
291
|
+
fs.unlinkSync(filePath);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function acquireLifecycleLock(options = {}) {
|
|
299
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
300
|
+
do {
|
|
301
|
+
const lock = tryAcquireLifecycleLock(options);
|
|
302
|
+
if (lock) return lock;
|
|
303
|
+
await new Promise((resolve) => setTimeout(resolve, 75));
|
|
304
|
+
} while (Date.now() < deadline);
|
|
305
|
+
throw new Error('Another Marrow controller lifecycle operation is still in progress.');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function releaseLifecycleLock(lock) {
|
|
309
|
+
if (!lock) return;
|
|
310
|
+
try { fs.closeSync(lock.descriptor); } catch {}
|
|
311
|
+
try {
|
|
312
|
+
const stat = assertPrivatePath(lock.filePath, 'lifecycle lock');
|
|
313
|
+
if (stat.size > MAX_STATE_BYTES) return;
|
|
314
|
+
const value = JSON.parse(fs.readFileSync(lock.filePath, 'utf8'));
|
|
315
|
+
if (value?.nonce === lock.nonce) fs.unlinkSync(lock.filePath);
|
|
316
|
+
} catch {}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function removeStaleState(options = {}) {
|
|
320
|
+
const filePath = stateFile(options);
|
|
321
|
+
if (!fs.existsSync(filePath)) return;
|
|
322
|
+
assertPrivatePath(filePath, 'state file');
|
|
323
|
+
fs.unlinkSync(filePath);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function removeInvalidState(options = {}) {
|
|
327
|
+
const filePath = stateFile(options);
|
|
328
|
+
if (!fs.existsSync(filePath)) return false;
|
|
329
|
+
assertPrivatePath(filePath, 'state file');
|
|
330
|
+
fs.unlinkSync(filePath);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function stopRecordedController(state, options = {}) {
|
|
335
|
+
if (!pidAlive(state.pid)) return;
|
|
336
|
+
if (!isExpectedControllerProcess(state.pid, options.platform)) {
|
|
337
|
+
throw new Error('Refusing to terminate an unreachable process that cannot be verified as the Marrow controller.');
|
|
338
|
+
}
|
|
339
|
+
process.kill(state.pid, 'SIGTERM');
|
|
340
|
+
const stopped = await waitFor(async () => pidAlive(state.pid) ? null : true, STOP_TIMEOUT_MS);
|
|
341
|
+
if (!stopped) throw new Error('Marrow controller did not stop within the shutdown window.');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function startGovernanceController(options) {
|
|
345
|
+
if (!controllerSupportedPlatform(options.platform)) {
|
|
346
|
+
throw new Error('Persistent controller lifecycle is currently supported on Linux. Use --no-controller and run the sidecar under an owner-managed service.');
|
|
347
|
+
}
|
|
348
|
+
if (!options.apiKey) throw new Error('MARROW_API_KEY is required to start the Marrow controller.');
|
|
349
|
+
const directory = ensurePrivateDirectory(options);
|
|
350
|
+
const lock = await acquireLifecycleLock(options);
|
|
351
|
+
try {
|
|
352
|
+
const current = await controllerStatus(options);
|
|
353
|
+
if (current.active) return { ...current, changed: false };
|
|
354
|
+
if (current.state === 'stale') removeStaleState(options);
|
|
355
|
+
if (current.state === 'unreachable') {
|
|
356
|
+
const prior = readState(options);
|
|
357
|
+
await stopRecordedController(prior, options);
|
|
358
|
+
removeStaleState(options);
|
|
359
|
+
}
|
|
360
|
+
if (current.state === 'identity_mismatch') {
|
|
361
|
+
throw new Error('Controller process identity is ambiguous; refusing automatic replacement.');
|
|
362
|
+
}
|
|
363
|
+
if (current.state === 'invalid_state') throw new Error(current.error || 'Controller state is invalid.');
|
|
364
|
+
|
|
365
|
+
const logPath = path.join(directory, 'controller.log');
|
|
366
|
+
if (fs.existsSync(logPath)) assertPrivatePath(logPath, 'log file');
|
|
367
|
+
const logFd = fs.openSync(logPath, 'a', 0o600);
|
|
368
|
+
fs.chmodSync(logPath, 0o600);
|
|
369
|
+
const binPath = require.resolve('../bin/marrow-install.js');
|
|
370
|
+
const args = [
|
|
371
|
+
binPath,
|
|
372
|
+
'sidecar',
|
|
373
|
+
'--agent', options.agentId,
|
|
374
|
+
'--client', options.client,
|
|
375
|
+
'--profile', options.profile,
|
|
376
|
+
'--policy', options.policy,
|
|
377
|
+
];
|
|
378
|
+
let child;
|
|
379
|
+
try {
|
|
380
|
+
child = spawn(process.execPath, args, {
|
|
381
|
+
detached: true,
|
|
382
|
+
env: cleanControllerEnv(options),
|
|
383
|
+
stdio: ['ignore', logFd, logFd],
|
|
384
|
+
});
|
|
385
|
+
child.unref();
|
|
386
|
+
} finally {
|
|
387
|
+
fs.closeSync(logFd);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const active = await waitFor(async () => {
|
|
391
|
+
const status = await controllerStatus(options);
|
|
392
|
+
return status.active ? status : null;
|
|
393
|
+
}, START_TIMEOUT_MS);
|
|
394
|
+
if (!active) {
|
|
395
|
+
if (child?.pid && pidAlive(child.pid)) {
|
|
396
|
+
try { process.kill(child.pid, 'SIGTERM'); } catch {}
|
|
397
|
+
}
|
|
398
|
+
throw new Error('Marrow controller did not become healthy within the startup window.');
|
|
399
|
+
}
|
|
400
|
+
return { ...active, changed: true };
|
|
401
|
+
} finally {
|
|
402
|
+
releaseLifecycleLock(lock);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function stopGovernanceController(options = {}) {
|
|
407
|
+
if (!controllerSupportedPlatform(options.platform)) {
|
|
408
|
+
throw new Error('Persistent controller lifecycle is currently supported on Linux. No process was signaled.');
|
|
409
|
+
}
|
|
410
|
+
ensurePrivateDirectory(options);
|
|
411
|
+
const lock = await acquireLifecycleLock(options);
|
|
412
|
+
try {
|
|
413
|
+
let state;
|
|
414
|
+
try {
|
|
415
|
+
state = readState(options);
|
|
416
|
+
} catch {
|
|
417
|
+
const changed = removeInvalidState(options);
|
|
418
|
+
return { active: false, state: 'stopped', changed, exact_fix: null };
|
|
419
|
+
}
|
|
420
|
+
if (!state) return { active: false, state: 'stopped', changed: false, exact_fix: null };
|
|
421
|
+
const current = await controllerStatus(options);
|
|
422
|
+
if (current.state === 'identity_mismatch') {
|
|
423
|
+
throw new Error('Refusing to stop a controller whose PID is not bound to its authenticated endpoint.');
|
|
424
|
+
}
|
|
425
|
+
if (current.active || current.state === 'unreachable') {
|
|
426
|
+
await stopRecordedController(state, options);
|
|
427
|
+
}
|
|
428
|
+
removeStaleState(options);
|
|
429
|
+
return { active: false, state: 'stopped', changed: true, exact_fix: null };
|
|
430
|
+
} finally {
|
|
431
|
+
releaseLifecycleLock(lock);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function ensureGovernanceController(options) {
|
|
436
|
+
const current = await controllerStatus(options);
|
|
437
|
+
return current.active ? { ...current, changed: false } : startGovernanceController(options);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
module.exports = {
|
|
441
|
+
controllerDirectory,
|
|
442
|
+
controllerIdentity,
|
|
443
|
+
controllerSupportedPlatform,
|
|
444
|
+
controllerStatus,
|
|
445
|
+
ensureGovernanceController,
|
|
446
|
+
readState,
|
|
447
|
+
startGovernanceController,
|
|
448
|
+
stopGovernanceController,
|
|
449
|
+
};
|
|
@@ -29,6 +29,9 @@ function createPrivateDirectoryWithoutSymlinks(directory) {
|
|
|
29
29
|
if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(current) !== current) {
|
|
30
30
|
throw new Error('Sidecar state directory cannot contain symlinked path components.');
|
|
31
31
|
}
|
|
32
|
+
if ((stat.mode & 0o022) !== 0 && (stat.mode & 0o1000) === 0) {
|
|
33
|
+
throw new Error('Sidecar state directory cannot be nested under a non-sticky writable ancestor.');
|
|
34
|
+
}
|
|
32
35
|
}
|
|
33
36
|
return resolved;
|
|
34
37
|
}
|
|
@@ -90,12 +93,14 @@ function writePrivateJsonAtomic(filePath, value) {
|
|
|
90
93
|
}
|
|
91
94
|
}
|
|
92
95
|
|
|
93
|
-
function unlinkPrivateStateFile(filePath) {
|
|
96
|
+
function unlinkPrivateStateFile(filePath, expectedInstanceId) {
|
|
94
97
|
try {
|
|
95
98
|
const stat = fs.lstatSync(filePath);
|
|
96
99
|
const uid = currentUid();
|
|
97
100
|
if (!stat.isSymbolicLink() && stat.isFile() && (uid === null || stat.uid === uid)) {
|
|
98
|
-
fs.
|
|
101
|
+
const raw = stat.size <= 8 * 1024 ? fs.readFileSync(filePath, 'utf8') : '';
|
|
102
|
+
const current = raw ? JSON.parse(raw) : null;
|
|
103
|
+
if (current?.instance_id === expectedInstanceId) fs.unlinkSync(filePath);
|
|
99
104
|
}
|
|
100
105
|
} catch {}
|
|
101
106
|
}
|
|
@@ -131,6 +136,12 @@ async function startGovernanceSidecar(options, handlers) {
|
|
|
131
136
|
const instanceId = `sidecar-${crypto.randomUUID()}`;
|
|
132
137
|
const startedAt = new Date().toISOString();
|
|
133
138
|
let latestCoverage = null;
|
|
139
|
+
let latestMaintenance = {
|
|
140
|
+
state: handlers.maintain ? 'pending' : 'unavailable',
|
|
141
|
+
checked_at: null,
|
|
142
|
+
repaired: [],
|
|
143
|
+
exact_fix: handlers.maintain ? null : 'Run npx @getmarrow/install --repair in the managed project.',
|
|
144
|
+
};
|
|
134
145
|
|
|
135
146
|
const server = http.createServer(async (req, res) => {
|
|
136
147
|
try {
|
|
@@ -142,7 +153,13 @@ async function startGovernanceSidecar(options, handlers) {
|
|
|
142
153
|
}
|
|
143
154
|
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
144
155
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
145
|
-
return json(res, 200, {
|
|
156
|
+
return json(res, 200, {
|
|
157
|
+
ok: true,
|
|
158
|
+
instance_id: instanceId,
|
|
159
|
+
pid: process.pid,
|
|
160
|
+
started_at: startedAt,
|
|
161
|
+
maintenance: latestMaintenance,
|
|
162
|
+
});
|
|
146
163
|
}
|
|
147
164
|
if (req.method === 'GET' && url.pathname === '/coverage') {
|
|
148
165
|
latestCoverage = await handlers.coverage();
|
|
@@ -184,6 +201,18 @@ async function startGovernanceSidecar(options, handlers) {
|
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
const heartbeat = async () => {
|
|
204
|
+
if (handlers.maintain) {
|
|
205
|
+
try {
|
|
206
|
+
latestMaintenance = await handlers.maintain();
|
|
207
|
+
} catch {
|
|
208
|
+
latestMaintenance = {
|
|
209
|
+
state: 'attention_required',
|
|
210
|
+
checked_at: new Date().toISOString(),
|
|
211
|
+
repaired: [],
|
|
212
|
+
exact_fix: 'Run npx @getmarrow/install --repair in the managed project.',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
187
216
|
try {
|
|
188
217
|
latestCoverage = await handlers.heartbeat({ sidecarInstanceId: instanceId });
|
|
189
218
|
} catch {
|
|
@@ -199,7 +228,7 @@ async function startGovernanceSidecar(options, handlers) {
|
|
|
199
228
|
if (closed) return;
|
|
200
229
|
closed = true;
|
|
201
230
|
clearInterval(timer);
|
|
202
|
-
unlinkPrivateStateFile(stateFile);
|
|
231
|
+
unlinkPrivateStateFile(stateFile, instanceId);
|
|
203
232
|
process.off('SIGINT', close);
|
|
204
233
|
process.off('SIGTERM', close);
|
|
205
234
|
server.close();
|
package/src/governed-runner.js
CHANGED
|
@@ -14,6 +14,18 @@ const {
|
|
|
14
14
|
verifyActionPermit,
|
|
15
15
|
} = require('./enforcement-client');
|
|
16
16
|
const { startGovernanceSidecar } = require('./governance-sidecar');
|
|
17
|
+
const {
|
|
18
|
+
controllerStatus,
|
|
19
|
+
ensureGovernanceController,
|
|
20
|
+
startGovernanceController,
|
|
21
|
+
stopGovernanceController,
|
|
22
|
+
} = require('./controller-manager');
|
|
23
|
+
const {
|
|
24
|
+
HARNESS_CAPABILITY_REGISTRY,
|
|
25
|
+
applyPlan,
|
|
26
|
+
buildPlan,
|
|
27
|
+
detectEnvironment,
|
|
28
|
+
} = require('./installer');
|
|
17
29
|
|
|
18
30
|
const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
|
|
19
31
|
const HIGH_RISK_TERMS = /\b(deploy|prod|production|publish|release|merge|migration|migrate|secret|token|key|cloudflare|wrangler|npm publish|gh pr merge|git push|terraform apply|kubectl apply|delete|destroy|drop)\b/i;
|
|
@@ -100,6 +112,8 @@ Commands:
|
|
|
100
112
|
verify-permit Verify a permit before CI, deploy, publish, merge, migration, or credential access
|
|
101
113
|
coverage Show enforcement, hook-health, closure, and bypass coverage
|
|
102
114
|
sidecar Run the loopback-only Marrow governance sidecar
|
|
115
|
+
controller <ensure|start|status|stop>
|
|
116
|
+
Keep the loopback governance controller active across agent sessions
|
|
103
117
|
govern Interactive setup TUI when run in a terminal; text panel in CI/non-TTY
|
|
104
118
|
fleet Fleet operator TUI for live agents, workflows, gates, proof debt, and exact fixes
|
|
105
119
|
integrations List Marrow-supported harness add-ons
|
|
@@ -431,6 +445,16 @@ function parseArgs(argv) {
|
|
|
431
445
|
return { command, options };
|
|
432
446
|
}
|
|
433
447
|
|
|
448
|
+
if (command === 'controller') {
|
|
449
|
+
const action = argv[1] && !argv[1].startsWith('--') ? argv[1] : 'status';
|
|
450
|
+
if (!['ensure', 'start', 'status', 'stop'].includes(action)) {
|
|
451
|
+
throw new Error('controller action must be ensure, start, status, or stop');
|
|
452
|
+
}
|
|
453
|
+
const parsed = parseBaseOptions(argv, action === 'status' && argv[1] !== 'status' ? 1 : 2);
|
|
454
|
+
if (parsed.options.help) return { command: 'help' };
|
|
455
|
+
return { command, action, options: parsed.options };
|
|
456
|
+
}
|
|
457
|
+
|
|
434
458
|
if (command === 'status' || command === 'govern' || command === 'fleet' || command === 'hermes' || command === 'openclaw' || command === 'integrations' || command === 'coverage' || command === 'sidecar') {
|
|
435
459
|
const parsed = parseBaseOptions(argv, 1);
|
|
436
460
|
if (parsed.options.help) return { command: 'help' };
|
|
@@ -687,13 +711,18 @@ async function runGoverned(parsed) {
|
|
|
687
711
|
let decision = null;
|
|
688
712
|
let decisionId = '';
|
|
689
713
|
let actionPermit = null;
|
|
690
|
-
let permitEnforcementStarted = false;
|
|
691
714
|
let permitVerified = false;
|
|
715
|
+
let degraded = false;
|
|
716
|
+
let protectedAction = risky;
|
|
692
717
|
const surfaces = inferSurfaces(commandText || action);
|
|
693
718
|
|
|
694
719
|
try {
|
|
695
720
|
runtime = await preflightRuntime(options, action, type, commandText);
|
|
696
721
|
decision = gateDecision(runtime);
|
|
722
|
+
protectedAction = risky
|
|
723
|
+
|| decision?.required === true
|
|
724
|
+
|| decision?.riskLevel === 'high'
|
|
725
|
+
|| decision?.riskLevel === 'critical';
|
|
697
726
|
printGate(decision, runtime);
|
|
698
727
|
if (shouldBlock(decision, options)) {
|
|
699
728
|
return {
|
|
@@ -710,38 +739,37 @@ async function runGoverned(parsed) {
|
|
|
710
739
|
const target = options.target || commandText;
|
|
711
740
|
const think = await createDecision(options, action, type, target, surfaces);
|
|
712
741
|
decisionId = think.decision_id || think.id || think.decision?.id || '';
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
742
|
+
if (protectedAction) {
|
|
743
|
+
actionPermit = await issueActionPermit(requestJson, options, {
|
|
744
|
+
action,
|
|
745
|
+
type,
|
|
746
|
+
target,
|
|
747
|
+
surfaces,
|
|
748
|
+
decisionId,
|
|
749
|
+
gateReceiptId: decision?.receiptId || '',
|
|
750
|
+
ownerApproval: options.ownerApproval,
|
|
751
|
+
proofRequirements: decision?.proofPack?.required_fields || decision?.proofPack?.missing || [],
|
|
752
|
+
});
|
|
753
|
+
if (!actionPermit?.permit || !actionPermit?.permit_id) {
|
|
754
|
+
throw new Error('Marrow did not issue a valid action permit.');
|
|
755
|
+
}
|
|
756
|
+
const verified = await verifyActionPermit(requestJson, options, {
|
|
757
|
+
action,
|
|
758
|
+
type,
|
|
759
|
+
target,
|
|
760
|
+
surfaces,
|
|
761
|
+
permit: actionPermit.permit,
|
|
762
|
+
});
|
|
763
|
+
if (verified?.verified !== true) throw new Error('Marrow action permit verification failed.');
|
|
764
|
+
permitVerified = true;
|
|
726
765
|
}
|
|
727
|
-
const verified = await verifyActionPermit(requestJson, options, {
|
|
728
|
-
action,
|
|
729
|
-
type,
|
|
730
|
-
target,
|
|
731
|
-
surfaces,
|
|
732
|
-
permit: actionPermit.permit,
|
|
733
|
-
});
|
|
734
|
-
if (verified?.verified !== true) throw new Error('Marrow action permit verification failed.');
|
|
735
|
-
permitVerified = true;
|
|
736
766
|
} catch (error) {
|
|
737
|
-
const
|
|
738
|
-
|| decision?.required === true
|
|
739
|
-
|| decision?.riskLevel === 'high'
|
|
740
|
-
|| decision?.riskLevel === 'critical';
|
|
741
|
-
const canDegrade = !permitEnforcementStarted
|
|
742
|
-
&& !protectedAction
|
|
767
|
+
const canDegrade = !protectedAction
|
|
743
768
|
&& (options.failOpen || !options.failClosed);
|
|
744
769
|
if (canDegrade) {
|
|
770
|
+
actionPermit = null;
|
|
771
|
+
permitVerified = false;
|
|
772
|
+
degraded = true;
|
|
745
773
|
process.stderr.write(`Marrow degraded: ${error.message}. Continuing because fail-open/non-risky policy allows it.\n`);
|
|
746
774
|
} else {
|
|
747
775
|
return {
|
|
@@ -757,7 +785,7 @@ async function runGoverned(parsed) {
|
|
|
757
785
|
}
|
|
758
786
|
}
|
|
759
787
|
|
|
760
|
-
const childEnv = scopedExecutionEnv(actionPermit);
|
|
788
|
+
const childEnv = scopedExecutionEnv(permitVerified ? actionPermit : null);
|
|
761
789
|
const child = await runChild(childCommand, childEnv);
|
|
762
790
|
const success = child.exitCode === 0;
|
|
763
791
|
const proof = defaultProof({ options, action, childCommand, exitCode: child.exitCode, success });
|
|
@@ -796,6 +824,7 @@ async function runGoverned(parsed) {
|
|
|
796
824
|
action,
|
|
797
825
|
type,
|
|
798
826
|
risky,
|
|
827
|
+
degraded,
|
|
799
828
|
decision,
|
|
800
829
|
decision_id: decisionId,
|
|
801
830
|
outcome_committed: Boolean(commit),
|
|
@@ -853,18 +882,54 @@ async function coverageOnly(parsed) {
|
|
|
853
882
|
|
|
854
883
|
async function sidecarOnly(parsed) {
|
|
855
884
|
const options = parsed.options;
|
|
885
|
+
const root = path.resolve(process.env.MARROW_CONTROLLER_PROJECT_ROOT || process.cwd());
|
|
886
|
+
const managedMode = process.env.MARROW_CONTROLLER_MANAGED_MODE || 'auto';
|
|
887
|
+
let lastMaintenanceAt = 0;
|
|
888
|
+
let lastMaintenance = null;
|
|
889
|
+
const maintain = async () => {
|
|
890
|
+
if (lastMaintenance && Date.now() - lastMaintenanceAt < 5 * 60_000) return lastMaintenance;
|
|
891
|
+
const detection = detectEnvironment(root, process.env);
|
|
892
|
+
const plan = buildPlan(detection, { mode: managedMode });
|
|
893
|
+
const changes = applyPlan(plan, { yes: true, dryRun: false, doctor: false });
|
|
894
|
+
const repaired = changes.filter((change) => change.applied).map((change) => change.label);
|
|
895
|
+
const remaining = changes.filter((change) => change.changed && !change.applied).map((change) => change.label);
|
|
896
|
+
lastMaintenanceAt = Date.now();
|
|
897
|
+
lastMaintenance = {
|
|
898
|
+
state: remaining.length > 0 ? 'attention_required' : repaired.length > 0 ? 'repaired' : 'clear',
|
|
899
|
+
checked_at: new Date(lastMaintenanceAt).toISOString(),
|
|
900
|
+
repaired,
|
|
901
|
+
exact_fix: remaining.length > 0 ? 'Run npx @getmarrow/install --repair in the managed project.' : null,
|
|
902
|
+
};
|
|
903
|
+
return lastMaintenance;
|
|
904
|
+
};
|
|
856
905
|
const sidecar = await startGovernanceSidecar(options, {
|
|
857
906
|
permit: (input) => issueActionPermit(requestJson, options, input),
|
|
858
907
|
verify: (input) => verifyActionPermit(requestJson, options, input),
|
|
859
908
|
close: (input) => closeActionPermit(requestJson, options, input),
|
|
860
909
|
coverage: () => readEnforcementCoverage(requestJson, options),
|
|
861
910
|
heartbeat: (input) => recordEnforcementHeartbeat(requestJson, options, input),
|
|
911
|
+
maintain,
|
|
862
912
|
});
|
|
863
913
|
process.stdout.write(`Marrow governance sidecar active on 127.0.0.1:${sidecar.port}. Press Ctrl+C to stop.\n`);
|
|
864
914
|
await new Promise((resolve) => sidecar.server.once('close', resolve));
|
|
865
915
|
return { ok: true };
|
|
866
916
|
}
|
|
867
917
|
|
|
918
|
+
async function controllerOnly(parsed) {
|
|
919
|
+
const options = { ...parsed.options, root: process.cwd(), mode: 'auto' };
|
|
920
|
+
let result;
|
|
921
|
+
if (parsed.action === 'ensure') result = await ensureGovernanceController(options);
|
|
922
|
+
else if (parsed.action === 'start') result = await startGovernanceController(options);
|
|
923
|
+
else if (parsed.action === 'stop') result = await stopGovernanceController(options);
|
|
924
|
+
else result = await controllerStatus(options);
|
|
925
|
+
if (!options.json) {
|
|
926
|
+
process.stdout.write(`Marrow controller: ${result.active ? 'active' : result.state}.\n`);
|
|
927
|
+
if (result.started_at) process.stdout.write(`Started: ${result.started_at}\n`);
|
|
928
|
+
if (result.exact_fix) process.stdout.write(`Next: ${result.exact_fix}\n`);
|
|
929
|
+
}
|
|
930
|
+
return { ok: result.active || parsed.action === 'stop', controller: result };
|
|
931
|
+
}
|
|
932
|
+
|
|
868
933
|
async function gateOnly(parsed) {
|
|
869
934
|
const { options } = parsed;
|
|
870
935
|
const action = redact(options.action);
|
|
@@ -1295,7 +1360,7 @@ function fleetPanel(snapshot) {
|
|
|
1295
1360
|
const GENERIC_GOVERNED_COMMAND = 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent <agent-id> --profile production --policy warn -- <harness-command>';
|
|
1296
1361
|
|
|
1297
1362
|
function localSupportedHarnesses() {
|
|
1298
|
-
|
|
1363
|
+
const harnesses = [
|
|
1299
1364
|
{ 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 --agent codex-prod -- codex' },
|
|
1300
1365
|
{ 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 npx @getmarrow/mcp setup' },
|
|
1301
1366
|
{ display_name: 'Cursor', client_label: 'cursor', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/mcp setup' },
|
|
@@ -1316,6 +1381,63 @@ function localSupportedHarnesses() {
|
|
|
1316
1381
|
{ 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 --agent ci-release --profile production --policy enforce -- <ci-or-deploy-command>' },
|
|
1317
1382
|
{ 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' },
|
|
1318
1383
|
];
|
|
1384
|
+
return harnesses.map((harness) => {
|
|
1385
|
+
const capability = HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === harness.client_label)
|
|
1386
|
+
|| HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === 'custom');
|
|
1387
|
+
return { ...harness, capability_level: capability.capability_level };
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function integrationCoverageMatrix() {
|
|
1392
|
+
return HARNESS_CAPABILITY_REGISTRY.map((entry) => {
|
|
1393
|
+
const native = entry.capability_level === 'native_hooks';
|
|
1394
|
+
const wrapper = entry.capability_level === 'governed_wrapper';
|
|
1395
|
+
const routed = entry.capability_level === 'mcp';
|
|
1396
|
+
const sdk = entry.capability_level === 'sdk_passive_runtime';
|
|
1397
|
+
return {
|
|
1398
|
+
harness: entry.client,
|
|
1399
|
+
capability_level: entry.capability_level,
|
|
1400
|
+
install_surface: entry.install_surface,
|
|
1401
|
+
pre_action: native
|
|
1402
|
+
? 'automatic_native_hook'
|
|
1403
|
+
: wrapper
|
|
1404
|
+
? 'automatic_in_governed_runner'
|
|
1405
|
+
: sdk
|
|
1406
|
+
? 'automatic_in_sdk_runtime'
|
|
1407
|
+
: routed
|
|
1408
|
+
? 'mcp_routed'
|
|
1409
|
+
: 'adapter_required',
|
|
1410
|
+
action_result: native
|
|
1411
|
+
? 'automatic_native_hook'
|
|
1412
|
+
: wrapper
|
|
1413
|
+
? 'automatic_in_governed_runner'
|
|
1414
|
+
: sdk
|
|
1415
|
+
? 'automatic_in_sdk_runtime'
|
|
1416
|
+
: routed
|
|
1417
|
+
? 'mcp_routed'
|
|
1418
|
+
: 'adapter_required',
|
|
1419
|
+
outcome_closure: wrapper || sdk
|
|
1420
|
+
? 'automatic_when_result_is_known'
|
|
1421
|
+
: native
|
|
1422
|
+
? 'correlated_when_determinable'
|
|
1423
|
+
: routed
|
|
1424
|
+
? 'mcp_routed'
|
|
1425
|
+
: 'adapter_required',
|
|
1426
|
+
proof_enforcement: native || wrapper || sdk
|
|
1427
|
+
? 'automatic_for_protected_actions'
|
|
1428
|
+
: routed
|
|
1429
|
+
? 'explicit_or_governed_wrapper'
|
|
1430
|
+
: 'adapter_required',
|
|
1431
|
+
automatic_repair: native || routed || sdk || wrapper ? 'installer_managed_config_only' : 'adapter_owned',
|
|
1432
|
+
limitation: native
|
|
1433
|
+
? 'A successful tool exit is not treated as a successful business outcome when the result cannot be proven.'
|
|
1434
|
+
: routed
|
|
1435
|
+
? 'Only actions routed through the MCP client are visible automatically.'
|
|
1436
|
+
: wrapper
|
|
1437
|
+
? 'Only commands launched through the governed wrapper receive full automatic coverage.'
|
|
1438
|
+
: 'The harness must emit the documented lifecycle event contract.',
|
|
1439
|
+
};
|
|
1440
|
+
});
|
|
1319
1441
|
}
|
|
1320
1442
|
|
|
1321
1443
|
function detectHarnesses(cwd = process.cwd()) {
|
|
@@ -1448,16 +1570,22 @@ async function integrationOnly(parsed, name) {
|
|
|
1448
1570
|
|
|
1449
1571
|
async function integrationsOnly(parsed) {
|
|
1450
1572
|
const local = {
|
|
1451
|
-
integration_registry_version: 'local.
|
|
1573
|
+
integration_registry_version: 'local.automatic-control-v3',
|
|
1452
1574
|
first_class_addons: ['hermes', 'openclaw'].map((name) => localIntegrationManifest(name)),
|
|
1453
1575
|
supported_harnesses: localSupportedHarnesses(),
|
|
1576
|
+
integration_coverage: integrationCoverageMatrix(),
|
|
1454
1577
|
exact_next_action: 'Pick the harness your team already uses. Use the install_command shown here, or send compact events to /v1/agent/integrations/events.',
|
|
1455
1578
|
};
|
|
1456
1579
|
let registry = local;
|
|
1457
1580
|
let source = 'local';
|
|
1458
1581
|
if (parsed.options.apiKey) {
|
|
1459
1582
|
try {
|
|
1460
|
-
|
|
1583
|
+
const remote = await requestJson(parsed.options, 'GET', '/v1/agent/integrations');
|
|
1584
|
+
registry = {
|
|
1585
|
+
...local,
|
|
1586
|
+
...remote,
|
|
1587
|
+
integration_coverage: local.integration_coverage,
|
|
1588
|
+
};
|
|
1461
1589
|
source = 'api';
|
|
1462
1590
|
} catch (error) {
|
|
1463
1591
|
registry = { ...local, api_warning: error instanceof Error ? error.message : String(error) };
|
|
@@ -1465,6 +1593,7 @@ async function integrationsOnly(parsed) {
|
|
|
1465
1593
|
}
|
|
1466
1594
|
if (parsed.options.json) return { ok: true, source, registry };
|
|
1467
1595
|
const harnesses = Array.isArray(registry.supported_harnesses) ? registry.supported_harnesses : [];
|
|
1596
|
+
const coverage = Array.isArray(registry.integration_coverage) ? registry.integration_coverage : [];
|
|
1468
1597
|
process.stdout.write([
|
|
1469
1598
|
'Marrow Harness Integrations',
|
|
1470
1599
|
'',
|
|
@@ -1472,6 +1601,9 @@ async function integrationsOnly(parsed) {
|
|
|
1472
1601
|
'Supported harnesses and model CLIs:',
|
|
1473
1602
|
...harnesses.map((harness) => ` - ${displayText(harness.display_name || harness.client_label || harness.integration || harness.title, 40)} [${displayText(harness.support_level || 'supported', 24)}] ${displayText(harness.install_command || harness.install_commands?.[0] || '', 120)}`),
|
|
1474
1603
|
'',
|
|
1604
|
+
'Automatic lifecycle coverage:',
|
|
1605
|
+
...coverage.map((entry) => ` - ${displayText(entry.harness, 24)}: pre=${entry.pre_action}; result=${entry.action_result}; closure=${entry.outcome_closure}; proof=${entry.proof_enforcement}; repair=${entry.automatic_repair}`),
|
|
1606
|
+
'',
|
|
1475
1607
|
'First-class add-on guides: hermes, openclaw',
|
|
1476
1608
|
`Next: ${displayText(registry.exact_next_action, 140)}`,
|
|
1477
1609
|
registry.api_warning ? `API warning: ${displayText(registry.api_warning, 120)}` : '',
|
|
@@ -2102,6 +2234,7 @@ async function runCli(argv) {
|
|
|
2102
2234
|
else if (parsed.command === 'verify-permit') result = await verifyPermitOnly(parsed);
|
|
2103
2235
|
else if (parsed.command === 'coverage') result = await coverageOnly(parsed);
|
|
2104
2236
|
else if (parsed.command === 'sidecar') result = await sidecarOnly(parsed);
|
|
2237
|
+
else if (parsed.command === 'controller') result = await controllerOnly(parsed);
|
|
2105
2238
|
else if (parsed.command === 'govern') {
|
|
2106
2239
|
await runGovernInteractive(parsed.options);
|
|
2107
2240
|
return;
|
|
@@ -2152,6 +2285,7 @@ module.exports = {
|
|
|
2152
2285
|
verifyPermitOnly,
|
|
2153
2286
|
coverageOnly,
|
|
2154
2287
|
sidecarOnly,
|
|
2288
|
+
controllerOnly,
|
|
2155
2289
|
actionBinding,
|
|
2156
2290
|
gateOnly,
|
|
2157
2291
|
proofOnly,
|
|
@@ -2168,6 +2302,7 @@ module.exports = {
|
|
|
2168
2302
|
renderFleetTui,
|
|
2169
2303
|
runFleetInteractive,
|
|
2170
2304
|
localSupportedHarnesses,
|
|
2305
|
+
integrationCoverageMatrix,
|
|
2171
2306
|
localIntegrationManifest,
|
|
2172
2307
|
renderIntegrationPanel,
|
|
2173
2308
|
integrationOnly,
|
package/src/installer.js
CHANGED
|
@@ -3,13 +3,14 @@ const path = require('node:path');
|
|
|
3
3
|
const os = require('node:os');
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
5
|
const { version: INSTALLER_ADAPTER_VERSION } = require('../package.json');
|
|
6
|
+
const { controllerStatus, controllerSupportedPlatform, ensureGovernanceController } = require('./controller-manager');
|
|
6
7
|
|
|
7
8
|
const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
|
|
8
9
|
const MARROW_BLOCK_START = '<!-- marrow:passive-start -->';
|
|
9
10
|
const MARROW_BLOCK_END = '<!-- marrow:passive-end -->';
|
|
10
|
-
const MCP_ADAPTER_VERSION = '3.9.
|
|
11
|
-
const SDK_ADAPTER_VERSION = '3.7.
|
|
12
|
-
const SDK_ADAPTER_INTEGRITY = 'sha512-
|
|
11
|
+
const MCP_ADAPTER_VERSION = '3.9.54';
|
|
12
|
+
const SDK_ADAPTER_VERSION = '3.7.53';
|
|
13
|
+
const SDK_ADAPTER_INTEGRITY = 'sha512-CKPVR9gf24mNvZIcFvKhHGZSsUhxa0uDg/bN4dktLlttB/EU/p6CEVWmGqZmxDTnfp4I+qqdJUpzuQSnkNhaIA==';
|
|
13
14
|
const SDK_ADAPTER_TARBALL = `https://registry.npmjs.org/@getmarrow/sdk/-/sdk-${SDK_ADAPTER_VERSION}.tgz`;
|
|
14
15
|
const MCP_PACKAGE_SPEC = `@getmarrow/mcp@${MCP_ADAPTER_VERSION}`;
|
|
15
16
|
const MCP_CONTEXT_HOOK_COMMAND = `npx -y ${MCP_PACKAGE_SPEC} context-hook`;
|
|
@@ -18,7 +19,7 @@ const MCP_ACTION_RESULT_HOOK_COMMAND = `npx -y ${MCP_PACKAGE_SPEC} hook`;
|
|
|
18
19
|
const MCP_SESSION_END_HOOK_COMMAND = `npx -y ${MCP_PACKAGE_SPEC} session-hook`;
|
|
19
20
|
const NATIVE_HOOK_MATCHER = 'Bash|Edit|Write|MultiEdit|mcp__(?!marrow_).*';
|
|
20
21
|
const NATIVE_EXPECTED_HOOKS = ['prompt', 'pre_action', 'action_result', 'session_end'];
|
|
21
|
-
const SOURCE_CLIENTS = new Set(['claude-code', 'cursor', 'composer', 'windsurf', 'openclaw', 'codex', 'gemini', 'grok', 'deepseek', 'qwen', 'kimi', 'minimax', 'cline', 'opencode', 'hermes', 'glm', 'custom', 'unknown']);
|
|
22
|
+
const SOURCE_CLIENTS = new Set(['claude-code', 'cursor', 'composer', 'windsurf', 'openclaw', 'codex', 'gemini', 'grok', 'deepseek', 'qwen', 'kimi', 'minimax', 'cline', 'opencode', 'hermes', 'glm', 'mcp', 'ci', 'custom', 'unknown']);
|
|
22
23
|
const HARNESS_CAPABILITY_REGISTRY = Object.freeze([
|
|
23
24
|
{ client: 'claude-code', capability_level: 'native_hooks', automatic: ['prompt', 'pre_action', 'action_result', 'session_end'], install_surface: 'mcp' },
|
|
24
25
|
{ client: 'cursor', capability_level: 'mcp', automatic: ['mcp_tool_calls'], install_surface: 'mcp' },
|
|
@@ -36,6 +37,8 @@ const HARNESS_CAPABILITY_REGISTRY = Object.freeze([
|
|
|
36
37
|
{ client: 'kimi', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
|
|
37
38
|
{ client: 'minimax', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
|
|
38
39
|
{ client: 'glm', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
|
|
40
|
+
{ client: 'mcp', capability_level: 'mcp', automatic: ['mcp_tool_calls'], install_surface: 'mcp' },
|
|
41
|
+
{ client: 'ci', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
|
|
39
42
|
{ client: 'custom', capability_level: 'event_contract', automatic: [], install_surface: 'event_contract' },
|
|
40
43
|
]);
|
|
41
44
|
|
|
@@ -64,6 +67,9 @@ function sourceClient() {
|
|
|
64
67
|
hermes: 'hermes',
|
|
65
68
|
'hermes-agent': 'hermes',
|
|
66
69
|
glm: 'glm',
|
|
70
|
+
mcp: 'mcp',
|
|
71
|
+
ci: 'ci',
|
|
72
|
+
'github-actions': 'ci',
|
|
67
73
|
};
|
|
68
74
|
return aliases[raw] || (SOURCE_CLIENTS.has(raw) ? raw : 'custom');
|
|
69
75
|
}
|
|
@@ -83,6 +89,7 @@ function parseArgs(argv) {
|
|
|
83
89
|
selfTestExplicitlyDisabled: false,
|
|
84
90
|
json: false,
|
|
85
91
|
activate: false,
|
|
92
|
+
controller: true,
|
|
86
93
|
};
|
|
87
94
|
|
|
88
95
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -104,6 +111,7 @@ function parseArgs(argv) {
|
|
|
104
111
|
options.selfTest = false;
|
|
105
112
|
options.selfTestExplicitlyDisabled = true;
|
|
106
113
|
}
|
|
114
|
+
else if (arg === '--no-controller') options.controller = false;
|
|
107
115
|
else if (arg === '--self-test') options.selfTest = true;
|
|
108
116
|
else if (arg === '--cwd') options.cwd = path.resolve(argv[++i] || options.cwd);
|
|
109
117
|
else if (arg === '--mode') options.mode = argv[++i] || options.mode;
|
|
@@ -157,6 +165,7 @@ Options:
|
|
|
157
165
|
--key <key> Marrow API key for self-test. Prefer MARROW_API_KEY because CLI args can appear in process listings.
|
|
158
166
|
--base-url <url> Marrow API base URL
|
|
159
167
|
--agent-id <id> Agent/fleet id for self-test headers
|
|
168
|
+
--no-controller Do not start the local background controller during install/repair
|
|
160
169
|
--no-self-test Skip API smoke/self-test
|
|
161
170
|
`;
|
|
162
171
|
}
|
|
@@ -435,6 +444,7 @@ Marrow should run passively after install:
|
|
|
435
444
|
- Keep passive token/model usage proof enabled so Marrow can show token, cost, latency, and workflow savings after real work completes.
|
|
436
445
|
- Before risky work, use Marrow's decision brief or passive prompt hook.
|
|
437
446
|
- After meaningful work, record the outcome so future agents learn from it.
|
|
447
|
+
- After Marrow blocks, warns, or requires review, use the decision trace receipt to tell the operator what changed and which recorded workflow or proof is required. Stay quiet for routine low-risk work.
|
|
438
448
|
- Check health with \`marrow_agent_status\` or \`GET /v1/agent/status\`.
|
|
439
449
|
- When status/runtime returns a \`client_update\` notice, tell the operator and use its exact update and verification commands only when local change policy permits.
|
|
440
450
|
|
|
@@ -1263,8 +1273,13 @@ async function runSelfTest(options) {
|
|
|
1263
1273
|
avoided_mistakes: performance.avoided_mistakes ?? performance.avoided_repeated_mistakes ?? 0,
|
|
1264
1274
|
reused_winning_decisions: performance.reused_winning_decisions ?? 0,
|
|
1265
1275
|
prevented_bad_actions: performance.prevented_bad_actions ?? 0,
|
|
1266
|
-
estimated_tokens_saved:
|
|
1267
|
-
estimated_minutes_saved:
|
|
1276
|
+
estimated_tokens_saved: tokenValueProof?.savings?.estimated_tokens_saved ?? null,
|
|
1277
|
+
estimated_minutes_saved: tokenValueProof?.savings?.estimated_minutes_saved ?? null,
|
|
1278
|
+
token_savings_available: Number(tokenValueProof?.observed?.model_calls || 0) > 0
|
|
1279
|
+
&& Number(tokenValueProof?.savings?.estimated_tokens_saved || 0) > 0,
|
|
1280
|
+
token_savings_source: 'agent_model_usage_events',
|
|
1281
|
+
token_savings_method: tokenValueProof?.savings?.method || 'warming_up',
|
|
1282
|
+
token_savings_confidence: tokenValueProof?.savings?.confidence || 'none',
|
|
1268
1283
|
reliability_score: performance.agent_reliability_score ?? null,
|
|
1269
1284
|
} : null,
|
|
1270
1285
|
};
|
|
@@ -1287,10 +1302,22 @@ function buildTokenValueProof(valueProof = {}) {
|
|
|
1287
1302
|
return modelUsage;
|
|
1288
1303
|
}
|
|
1289
1304
|
|
|
1305
|
+
function tokenValueProofLine(tokenValueProof) {
|
|
1306
|
+
const calls = Number(tokenValueProof?.observed?.model_calls || 0);
|
|
1307
|
+
const saved = Number(tokenValueProof?.savings?.estimated_tokens_saved || 0);
|
|
1308
|
+
if (calls > 0 && saved > 0) {
|
|
1309
|
+
const method = tokenValueProof.savings?.method || 'unspecified';
|
|
1310
|
+
const confidence = tokenValueProof.savings?.confidence || 'unknown';
|
|
1311
|
+
return `Marrow observed ${calls} model call${calls === 1 ? '' : 's'} and estimates ~${saved} tokens saved (${method}, ${confidence} confidence)`;
|
|
1312
|
+
}
|
|
1313
|
+
return tokenValueProof?.proof_line || null;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1290
1316
|
function buildInstallValueMoment(firstValueSignal = {}, status = {}, runtime = {}, performance = {}, firstValue = {}, tokenValueProof = null) {
|
|
1291
1317
|
if (firstValue && firstValue.ok !== false && firstValue.first_value) {
|
|
1292
1318
|
const proof = Array.isArray(firstValue.first_value.proof) ? [...firstValue.first_value.proof] : [];
|
|
1293
|
-
|
|
1319
|
+
const tokenProofLine = tokenValueProofLine(tokenValueProof);
|
|
1320
|
+
if (tokenProofLine && !proof.includes(tokenProofLine)) proof.push(tokenProofLine);
|
|
1294
1321
|
return {
|
|
1295
1322
|
headline: firstValue.headline || firstValue.first_value.headline || 'Your agent is no longer starting from zero.',
|
|
1296
1323
|
proof,
|
|
@@ -1315,7 +1342,7 @@ function buildInstallValueMoment(firstValueSignal = {}, status = {}, runtime = {
|
|
|
1315
1342
|
'Closed the outcome successfully',
|
|
1316
1343
|
'Runtime gate is ' + (firstValueSignal.active ? 'active' : 'installed'),
|
|
1317
1344
|
runtimeLesson ? 'Future risky work now gets a pre-action brief' : 'Future risky work now gets checked before action',
|
|
1318
|
-
tokenValueProof
|
|
1345
|
+
tokenValueProofLine(tokenValueProof) || 'Token usage proof is active and warming up after the first model call',
|
|
1319
1346
|
],
|
|
1320
1347
|
fleet_signal: hasFleetSignal
|
|
1321
1348
|
? 'Marrow already found signal: ' + proof.join('; ') + '.'
|
|
@@ -1334,9 +1361,8 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
|
|
|
1334
1361
|
if (Number(proof.avoided_mistakes || 0) > 0) proofBits.push(`${proof.avoided_mistakes} avoided mistake(s)`);
|
|
1335
1362
|
if (Number(proof.reused_winning_decisions || 0) > 0) proofBits.push(`${proof.reused_winning_decisions} reused winning decision(s)`);
|
|
1336
1363
|
if (Number(proof.prevented_bad_actions || 0) > 0) proofBits.push(`${proof.prevented_bad_actions} prevented risky action(s)`);
|
|
1337
|
-
|
|
1338
|
-
if (
|
|
1339
|
-
else if (tokenValueProof?.proof_line) proofBits.push(tokenValueProof.proof_line);
|
|
1364
|
+
const tokenProofLine = tokenValueProofLine(tokenValueProof);
|
|
1365
|
+
if (tokenProofLine) proofBits.push(tokenProofLine);
|
|
1340
1366
|
return {
|
|
1341
1367
|
active: Boolean(firstValue.active),
|
|
1342
1368
|
headline: `Marrow active: ${(capture.surfaces || ['decisions']).join(', ')} captured.`,
|
|
@@ -1362,10 +1388,8 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
|
|
|
1362
1388
|
if (Number(proof.avoided_mistakes || proof.avoided_repeated_mistakes || 0) > 0) proofBits.push(`${proof.avoided_mistakes || proof.avoided_repeated_mistakes} avoided mistake(s)`);
|
|
1363
1389
|
if (Number(proof.reused_winning_decisions || 0) > 0) proofBits.push(`${proof.reused_winning_decisions} reused winning decision(s)`);
|
|
1364
1390
|
if (Number(proof.prevented_bad_actions || 0) > 0) proofBits.push(`${proof.prevented_bad_actions} prevented risky action(s)`);
|
|
1365
|
-
const
|
|
1366
|
-
if (
|
|
1367
|
-
if (Number(tokenValueProof?.savings?.estimated_tokens_saved || 0) > 0) proofBits.push(`~${tokenValueProof.savings.estimated_tokens_saved} measured model tokens saved`);
|
|
1368
|
-
else if (tokenValueProof?.proof_line) proofBits.push(tokenValueProof.proof_line);
|
|
1391
|
+
const tokenProofLine = tokenValueProofLine(tokenValueProof);
|
|
1392
|
+
if (tokenProofLine) proofBits.push(tokenProofLine);
|
|
1369
1393
|
|
|
1370
1394
|
const firstLesson = runtime.before_you_act
|
|
1371
1395
|
|| runtime.before_you_act_injection?.message
|
|
@@ -1495,6 +1519,11 @@ function printReport(report) {
|
|
|
1495
1519
|
if (report.sdkDependency.warning) process.stdout.write(`- warning: ${report.sdkDependency.warning}\n`);
|
|
1496
1520
|
}
|
|
1497
1521
|
|
|
1522
|
+
process.stdout.write('\nAutomatic controller:\n');
|
|
1523
|
+
process.stdout.write(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
|
|
1524
|
+
if (report.controller?.started_at) process.stdout.write(`- started: ${report.controller.started_at}\n`);
|
|
1525
|
+
if (report.controller?.exact_fix) process.stdout.write(`- exact fix: ${report.controller.exact_fix}\n`);
|
|
1526
|
+
|
|
1498
1527
|
if (report.writeMode === 'doctor') {
|
|
1499
1528
|
process.stdout.write('\nDoctor:\n');
|
|
1500
1529
|
process.stdout.write(`- Marrow active: ${report.doctor.active ? 'yes' : 'no'}\n`);
|
|
@@ -1573,6 +1602,43 @@ async function install(options) {
|
|
|
1573
1602
|
}
|
|
1574
1603
|
const changedConfig = changes.some((change) => change.applied) || configRepairs.some((repair) => repair.changed);
|
|
1575
1604
|
const selfTestPassed = Boolean(!selfTest.skipped && selfTest.active && !selfTest.error);
|
|
1605
|
+
const controllerPlatform = options.controllerPlatform || process.platform;
|
|
1606
|
+
let controller = await controllerStatus({
|
|
1607
|
+
root: detection.root,
|
|
1608
|
+
agentId: options.agentId,
|
|
1609
|
+
platform: controllerPlatform,
|
|
1610
|
+
});
|
|
1611
|
+
const shouldEnsureController = options.controller !== false
|
|
1612
|
+
&& controllerSupportedPlatform(controllerPlatform)
|
|
1613
|
+
&& Boolean(options.apiKey)
|
|
1614
|
+
&& selfTestPassed
|
|
1615
|
+
&& !options.dryRun
|
|
1616
|
+
&& !options.doctor
|
|
1617
|
+
&& (options.yes || options.activate || options.repair);
|
|
1618
|
+
if (shouldEnsureController) {
|
|
1619
|
+
try {
|
|
1620
|
+
controller = await ensureGovernanceController({
|
|
1621
|
+
apiKey: options.apiKey,
|
|
1622
|
+
baseUrl: options.baseUrl,
|
|
1623
|
+
agentId: options.agentId,
|
|
1624
|
+
client,
|
|
1625
|
+
root: detection.root,
|
|
1626
|
+
mode: plan.mode,
|
|
1627
|
+
profile: options.governanceMode || 'default',
|
|
1628
|
+
policy: options.governancePolicy || 'warn',
|
|
1629
|
+
platform: controllerPlatform,
|
|
1630
|
+
});
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1633
|
+
controller = {
|
|
1634
|
+
active: false,
|
|
1635
|
+
state: 'error',
|
|
1636
|
+
exact_fix: 'Run npx @getmarrow/install controller ensure after correcting the reported local controller error.',
|
|
1637
|
+
error: message,
|
|
1638
|
+
};
|
|
1639
|
+
if (options.activate) throw new Error(`Marrow activation failed: local controller did not start: ${message}`);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1576
1642
|
const remediation = options.repair
|
|
1577
1643
|
? {
|
|
1578
1644
|
attempted: true,
|
|
@@ -1614,16 +1680,17 @@ async function install(options) {
|
|
|
1614
1680
|
missingEnv: options.apiKey ? [] : ['MARROW_API_KEY'],
|
|
1615
1681
|
envHints,
|
|
1616
1682
|
missingHooks: changes.filter((change) => change.changed).map((change) => change.label),
|
|
1617
|
-
recommendedFix: configDiagnostics.npm_token.recommended_fix ||
|
|
1683
|
+
recommendedFix: configDiagnostics.npm_token.recommended_fix || (!options.apiKey
|
|
1618
1684
|
? envHints.length
|
|
1619
1685
|
? `MARROW_API_KEY was found in a likely env file at ${envHints[0]}. Load that key from trusted secret storage, export only MARROW_API_KEY, then run npx @getmarrow/install --repair.`
|
|
1620
1686
|
: 'Set MARROW_API_KEY, then run npx @getmarrow/install --repair.'
|
|
1621
|
-
: null),
|
|
1687
|
+
: controller.exact_fix || selfTest.recommended_fix || null),
|
|
1622
1688
|
},
|
|
1623
1689
|
remediation,
|
|
1624
1690
|
configDiagnostics,
|
|
1625
1691
|
configRepairs,
|
|
1626
1692
|
sdkDependency,
|
|
1693
|
+
controller,
|
|
1627
1694
|
selfTest,
|
|
1628
1695
|
warnings: options.keyFromArg
|
|
1629
1696
|
? ['Avoid --key in shared shells because command-line arguments can be visible in process listings. Prefer MARROW_API_KEY in your environment or secret manager.']
|