@getmarrow/install 0.1.36 → 0.1.37
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 +41 -2
- 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 +83 -17
package/README.md
CHANGED
|
@@ -74,9 +74,34 @@ 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.37
|
|
92
|
+
|
|
93
|
+
v0.1.37 adds the automatic local control lifecycle after explicit owner activation:
|
|
94
|
+
|
|
95
|
+
- a project-and-agent-scoped loopback controller survives individual agent sessions;
|
|
96
|
+
- installer-managed hooks are checked and safely restored without changing unrelated files;
|
|
97
|
+
- the governed runner automatically classifies consequential commands and requires fresh signed permits for protected actions;
|
|
98
|
+
- pre-action, execution, result, proof, and outcome receipts share stable correlation;
|
|
99
|
+
- integration coverage states exactly what is native, MCP-routed, governed-wrapper controlled, or adapter-required;
|
|
100
|
+
- in-session value messages use measured evidence only and report unavailable data instead of synthetic savings.
|
|
101
|
+
|
|
102
|
+
It preserves the update and permit controls introduced in v0.1.36.
|
|
103
|
+
|
|
104
|
+
## Previous: v0.1.36
|
|
80
105
|
|
|
81
106
|
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
107
|
|
|
@@ -200,6 +225,7 @@ npx @getmarrow/install permit --agent deploy-agent --type deploy --action "deplo
|
|
|
200
225
|
MARROW_ACTION_PERMIT=... npx @getmarrow/install verify-permit --agent deploy-agent --type deploy --action "deploy production"
|
|
201
226
|
npx @getmarrow/install coverage --agent deploy-agent
|
|
202
227
|
npx @getmarrow/install sidecar --agent deploy-agent
|
|
228
|
+
npx @getmarrow/install controller status
|
|
203
229
|
npx @getmarrow/install status
|
|
204
230
|
npx @getmarrow/install doctor
|
|
205
231
|
npx @getmarrow/install --repair
|
|
@@ -225,6 +251,19 @@ The fleet view shows live agents, active workflows, agent disagreements and thei
|
|
|
225
251
|
|
|
226
252
|
These are integration surfaces for one Marrow product, not separate products.
|
|
227
253
|
|
|
254
|
+
## Exact Integration Coverage
|
|
255
|
+
|
|
256
|
+
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.
|
|
257
|
+
|
|
258
|
+
| Harnesses | Pre-action | Result | Outcome closure | Proof enforcement | Safe repair |
|
|
259
|
+
| --- | --- | --- | --- | --- | --- |
|
|
260
|
+
| Claude Code | Automatic native hook | Automatic native hook | Correlated when determinable | Automatic for protected actions | Managed config after activation |
|
|
261
|
+
| Cursor, Composer, Cline, Windsurf | MCP-routed | MCP-routed | MCP-routed | Explicit or governed runner | Managed config after activation |
|
|
262
|
+
| 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 |
|
|
263
|
+
| Hermes, OpenClaw, custom harnesses | Adapter required | Adapter required | Adapter required | Adapter required | Adapter owned |
|
|
264
|
+
|
|
265
|
+
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.
|
|
266
|
+
|
|
228
267
|
## Always-On Lifecycle
|
|
229
268
|
|
|
230
269
|
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.
|
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.53';
|
|
12
|
+
const SDK_ADAPTER_VERSION = '3.7.52';
|
|
13
|
+
const SDK_ADAPTER_INTEGRITY = 'sha512-dMo5rMXP5sFTRsiRI+Oe3SOWgSsi8TTt9VrYL9gC71EQFN2UTtuH914CkYEpcS627sb02jXrUDiXxznX/2fWgA==';
|
|
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
|
}
|
|
@@ -1263,8 +1272,13 @@ async function runSelfTest(options) {
|
|
|
1263
1272
|
avoided_mistakes: performance.avoided_mistakes ?? performance.avoided_repeated_mistakes ?? 0,
|
|
1264
1273
|
reused_winning_decisions: performance.reused_winning_decisions ?? 0,
|
|
1265
1274
|
prevented_bad_actions: performance.prevented_bad_actions ?? 0,
|
|
1266
|
-
estimated_tokens_saved:
|
|
1267
|
-
estimated_minutes_saved:
|
|
1275
|
+
estimated_tokens_saved: tokenValueProof?.savings?.estimated_tokens_saved ?? null,
|
|
1276
|
+
estimated_minutes_saved: tokenValueProof?.savings?.estimated_minutes_saved ?? null,
|
|
1277
|
+
token_savings_available: Number(tokenValueProof?.observed?.model_calls || 0) > 0
|
|
1278
|
+
&& Number(tokenValueProof?.savings?.estimated_tokens_saved || 0) > 0,
|
|
1279
|
+
token_savings_source: 'agent_model_usage_events',
|
|
1280
|
+
token_savings_method: tokenValueProof?.savings?.method || 'warming_up',
|
|
1281
|
+
token_savings_confidence: tokenValueProof?.savings?.confidence || 'none',
|
|
1268
1282
|
reliability_score: performance.agent_reliability_score ?? null,
|
|
1269
1283
|
} : null,
|
|
1270
1284
|
};
|
|
@@ -1287,10 +1301,22 @@ function buildTokenValueProof(valueProof = {}) {
|
|
|
1287
1301
|
return modelUsage;
|
|
1288
1302
|
}
|
|
1289
1303
|
|
|
1304
|
+
function tokenValueProofLine(tokenValueProof) {
|
|
1305
|
+
const calls = Number(tokenValueProof?.observed?.model_calls || 0);
|
|
1306
|
+
const saved = Number(tokenValueProof?.savings?.estimated_tokens_saved || 0);
|
|
1307
|
+
if (calls > 0 && saved > 0) {
|
|
1308
|
+
const method = tokenValueProof.savings?.method || 'unspecified';
|
|
1309
|
+
const confidence = tokenValueProof.savings?.confidence || 'unknown';
|
|
1310
|
+
return `Marrow observed ${calls} model call${calls === 1 ? '' : 's'} and estimates ~${saved} tokens saved (${method}, ${confidence} confidence)`;
|
|
1311
|
+
}
|
|
1312
|
+
return tokenValueProof?.proof_line || null;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1290
1315
|
function buildInstallValueMoment(firstValueSignal = {}, status = {}, runtime = {}, performance = {}, firstValue = {}, tokenValueProof = null) {
|
|
1291
1316
|
if (firstValue && firstValue.ok !== false && firstValue.first_value) {
|
|
1292
1317
|
const proof = Array.isArray(firstValue.first_value.proof) ? [...firstValue.first_value.proof] : [];
|
|
1293
|
-
|
|
1318
|
+
const tokenProofLine = tokenValueProofLine(tokenValueProof);
|
|
1319
|
+
if (tokenProofLine && !proof.includes(tokenProofLine)) proof.push(tokenProofLine);
|
|
1294
1320
|
return {
|
|
1295
1321
|
headline: firstValue.headline || firstValue.first_value.headline || 'Your agent is no longer starting from zero.',
|
|
1296
1322
|
proof,
|
|
@@ -1315,7 +1341,7 @@ function buildInstallValueMoment(firstValueSignal = {}, status = {}, runtime = {
|
|
|
1315
1341
|
'Closed the outcome successfully',
|
|
1316
1342
|
'Runtime gate is ' + (firstValueSignal.active ? 'active' : 'installed'),
|
|
1317
1343
|
runtimeLesson ? 'Future risky work now gets a pre-action brief' : 'Future risky work now gets checked before action',
|
|
1318
|
-
tokenValueProof
|
|
1344
|
+
tokenValueProofLine(tokenValueProof) || 'Token usage proof is active and warming up after the first model call',
|
|
1319
1345
|
],
|
|
1320
1346
|
fleet_signal: hasFleetSignal
|
|
1321
1347
|
? 'Marrow already found signal: ' + proof.join('; ') + '.'
|
|
@@ -1334,9 +1360,8 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
|
|
|
1334
1360
|
if (Number(proof.avoided_mistakes || 0) > 0) proofBits.push(`${proof.avoided_mistakes} avoided mistake(s)`);
|
|
1335
1361
|
if (Number(proof.reused_winning_decisions || 0) > 0) proofBits.push(`${proof.reused_winning_decisions} reused winning decision(s)`);
|
|
1336
1362
|
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);
|
|
1363
|
+
const tokenProofLine = tokenValueProofLine(tokenValueProof);
|
|
1364
|
+
if (tokenProofLine) proofBits.push(tokenProofLine);
|
|
1340
1365
|
return {
|
|
1341
1366
|
active: Boolean(firstValue.active),
|
|
1342
1367
|
headline: `Marrow active: ${(capture.surfaces || ['decisions']).join(', ')} captured.`,
|
|
@@ -1362,10 +1387,8 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
|
|
|
1362
1387
|
if (Number(proof.avoided_mistakes || proof.avoided_repeated_mistakes || 0) > 0) proofBits.push(`${proof.avoided_mistakes || proof.avoided_repeated_mistakes} avoided mistake(s)`);
|
|
1363
1388
|
if (Number(proof.reused_winning_decisions || 0) > 0) proofBits.push(`${proof.reused_winning_decisions} reused winning decision(s)`);
|
|
1364
1389
|
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);
|
|
1390
|
+
const tokenProofLine = tokenValueProofLine(tokenValueProof);
|
|
1391
|
+
if (tokenProofLine) proofBits.push(tokenProofLine);
|
|
1369
1392
|
|
|
1370
1393
|
const firstLesson = runtime.before_you_act
|
|
1371
1394
|
|| runtime.before_you_act_injection?.message
|
|
@@ -1495,6 +1518,11 @@ function printReport(report) {
|
|
|
1495
1518
|
if (report.sdkDependency.warning) process.stdout.write(`- warning: ${report.sdkDependency.warning}\n`);
|
|
1496
1519
|
}
|
|
1497
1520
|
|
|
1521
|
+
process.stdout.write('\nAutomatic controller:\n');
|
|
1522
|
+
process.stdout.write(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
|
|
1523
|
+
if (report.controller?.started_at) process.stdout.write(`- started: ${report.controller.started_at}\n`);
|
|
1524
|
+
if (report.controller?.exact_fix) process.stdout.write(`- exact fix: ${report.controller.exact_fix}\n`);
|
|
1525
|
+
|
|
1498
1526
|
if (report.writeMode === 'doctor') {
|
|
1499
1527
|
process.stdout.write('\nDoctor:\n');
|
|
1500
1528
|
process.stdout.write(`- Marrow active: ${report.doctor.active ? 'yes' : 'no'}\n`);
|
|
@@ -1573,6 +1601,43 @@ async function install(options) {
|
|
|
1573
1601
|
}
|
|
1574
1602
|
const changedConfig = changes.some((change) => change.applied) || configRepairs.some((repair) => repair.changed);
|
|
1575
1603
|
const selfTestPassed = Boolean(!selfTest.skipped && selfTest.active && !selfTest.error);
|
|
1604
|
+
const controllerPlatform = options.controllerPlatform || process.platform;
|
|
1605
|
+
let controller = await controllerStatus({
|
|
1606
|
+
root: detection.root,
|
|
1607
|
+
agentId: options.agentId,
|
|
1608
|
+
platform: controllerPlatform,
|
|
1609
|
+
});
|
|
1610
|
+
const shouldEnsureController = options.controller !== false
|
|
1611
|
+
&& controllerSupportedPlatform(controllerPlatform)
|
|
1612
|
+
&& Boolean(options.apiKey)
|
|
1613
|
+
&& selfTestPassed
|
|
1614
|
+
&& !options.dryRun
|
|
1615
|
+
&& !options.doctor
|
|
1616
|
+
&& (options.yes || options.activate || options.repair);
|
|
1617
|
+
if (shouldEnsureController) {
|
|
1618
|
+
try {
|
|
1619
|
+
controller = await ensureGovernanceController({
|
|
1620
|
+
apiKey: options.apiKey,
|
|
1621
|
+
baseUrl: options.baseUrl,
|
|
1622
|
+
agentId: options.agentId,
|
|
1623
|
+
client,
|
|
1624
|
+
root: detection.root,
|
|
1625
|
+
mode: plan.mode,
|
|
1626
|
+
profile: options.governanceMode || 'default',
|
|
1627
|
+
policy: options.governancePolicy || 'warn',
|
|
1628
|
+
platform: controllerPlatform,
|
|
1629
|
+
});
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1632
|
+
controller = {
|
|
1633
|
+
active: false,
|
|
1634
|
+
state: 'error',
|
|
1635
|
+
exact_fix: 'Run npx @getmarrow/install controller ensure after correcting the reported local controller error.',
|
|
1636
|
+
error: message,
|
|
1637
|
+
};
|
|
1638
|
+
if (options.activate) throw new Error(`Marrow activation failed: local controller did not start: ${message}`);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1576
1641
|
const remediation = options.repair
|
|
1577
1642
|
? {
|
|
1578
1643
|
attempted: true,
|
|
@@ -1614,16 +1679,17 @@ async function install(options) {
|
|
|
1614
1679
|
missingEnv: options.apiKey ? [] : ['MARROW_API_KEY'],
|
|
1615
1680
|
envHints,
|
|
1616
1681
|
missingHooks: changes.filter((change) => change.changed).map((change) => change.label),
|
|
1617
|
-
recommendedFix: configDiagnostics.npm_token.recommended_fix ||
|
|
1682
|
+
recommendedFix: configDiagnostics.npm_token.recommended_fix || (!options.apiKey
|
|
1618
1683
|
? envHints.length
|
|
1619
1684
|
? `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
1685
|
: 'Set MARROW_API_KEY, then run npx @getmarrow/install --repair.'
|
|
1621
|
-
: null),
|
|
1686
|
+
: controller.exact_fix || selfTest.recommended_fix || null),
|
|
1622
1687
|
},
|
|
1623
1688
|
remediation,
|
|
1624
1689
|
configDiagnostics,
|
|
1625
1690
|
configRepairs,
|
|
1626
1691
|
sdkDependency,
|
|
1692
|
+
controller,
|
|
1627
1693
|
selfTest,
|
|
1628
1694
|
warnings: options.keyFromArg
|
|
1629
1695
|
? ['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.']
|