@getmarrow/install 0.1.35 → 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 CHANGED
@@ -74,19 +74,55 @@ 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. Detection and notification are automatic; local changes remain explicit and subject to the operator's normal change policy.
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
- ## What's New in v0.1.35
79
+ ## Automatic Controller
80
80
 
81
- v0.1.35 turns server-side client update detection into a guided, low-friction operator workflow. Installer status, activation reports, and the Fleet Operator expose a request-specific advisory with the exact update and verification commands while keeping local mutation explicit:
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
105
+
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
 
83
108
  - official installer requests identify the installed `@getmarrow/install` version;
84
109
  - status, self-test, and Fleet Operator output show recommended, unrecognized, and security-required update states without conflating them;
85
110
  - generated agent instructions tell the agent to notify the operator and obey local change policy;
86
- - certified activation now pins MCP 3.9.51 and SDK 3.7.50, including the exact SDK registry integrity;
111
+ - certified activation pins the matching MCP and SDK releases, including exact SDK registry integrity;
87
112
  - `activate`, `doctor`, and `--repair` remain explicit commands and preserve unrelated hooks and configuration.
88
113
 
89
- It preserves the passive-governance verification introduced in v0.1.34. Activation registers a bounded capability profile, a one-way configuration fingerprint, expected and observed hook surfaces, and a server-accepted lifecycle receipt. The Fleet Operator shows activation state, capture coverage, outcome closure, intervention follow-through, drift, and the exact repair:
114
+ The governed runner makes protected actions executable only through a short-lived, signed Marrow permit bound to the exact account, agent, session, action, target, canonical action surfaces, runtime gate, and decision before starting the child process. It then closes that permit with exact evidence and the real outcome:
115
+
116
+ - deploy, publish, merge, migration, credential, and other protected work fails closed when its permit cannot be verified;
117
+ - the child process receives only the scoped permit, never the Marrow API key through a new broker interface;
118
+ - permits are single-use, expire within minutes, and cannot be replayed for another agent, action, target, or session;
119
+ - `permit` and `verify-permit` provide deterministic CI choke points;
120
+ - the loopback `sidecar` keeps private state owner-only and reports hook/configuration drift;
121
+ - `coverage` reports permit closure, bypasses, stale sidecars, and hook health with exact repair steps;
122
+ - correlated result hooks can close evidence automatically, while incomplete protected work remains visible;
123
+ - controlled break-glass access requires an authenticated account owner, a current runtime gate, a reason, a short expiry, and evidence closure.
124
+
125
+ It preserves the measurable passive-governance coverage introduced in v0.1.34:
90
126
 
91
127
  - Claude Code installation includes exact `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, and `Stop` hooks;
92
128
  - matching pre-action/result receipts use one tool correlation, and activation fingerprints the exact hook contract without uploading configuration contents;
@@ -175,15 +211,21 @@ npx @getmarrow/install run \
175
211
  The runner:
176
212
 
177
213
  1. requests the Marrow runtime gate;
178
- 2. prints the decision, relevant lesson, owner-approval state, and required proof;
179
- 3. blocks when policy requires it;
180
- 4. runs the original command when allowed;
181
- 5. records success or failure and attaches a redacted proof pack.
214
+ 2. records the governed decision against that exact gate;
215
+ 3. requests and verifies a single-use permit bound to the exact action, target, and canonical action surfaces;
216
+ 4. blocks protected work if policy or permit verification fails;
217
+ 5. runs the original command with the scoped permit, not the Marrow API key;
218
+ 6. records success or failure, supplies every exact server-required proof field through a redacted proof pack, and closes the permit.
182
219
 
183
220
  Useful commands:
184
221
 
185
222
  ```bash
186
223
  npx @getmarrow/install gate --agent deploy-agent --type deploy --action "deploy production"
224
+ npx @getmarrow/install permit --agent deploy-agent --type deploy --action "deploy production"
225
+ MARROW_ACTION_PERMIT=... npx @getmarrow/install verify-permit --agent deploy-agent --type deploy --action "deploy production"
226
+ npx @getmarrow/install coverage --agent deploy-agent
227
+ npx @getmarrow/install sidecar --agent deploy-agent
228
+ npx @getmarrow/install controller status
187
229
  npx @getmarrow/install status
188
230
  npx @getmarrow/install doctor
189
231
  npx @getmarrow/install --repair
@@ -209,6 +251,19 @@ The fleet view shows live agents, active workflows, agent disagreements and thei
209
251
 
210
252
  These are integration surfaces for one Marrow product, not separate products.
211
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
+
212
267
  ## Always-On Lifecycle
213
268
 
214
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.
@@ -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']);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmarrow/install",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "Universal installer and governed runner for Marrow agent fleets.",
5
5
  "bin": {
6
6
  "marrow-install": "bin/marrow-install.js"
@@ -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
+ };
@@ -0,0 +1,94 @@
1
+ const crypto = require('node:crypto');
2
+
3
+ function sha256(value) {
4
+ return crypto.createHash('sha256').update(String(value || '')).digest('hex');
5
+ }
6
+
7
+ function actionBinding(input) {
8
+ const action = String(input.action || '').trim();
9
+ const actionType = String(input.type || 'general').trim().toLowerCase();
10
+ const target = String(input.target || '').trim();
11
+ return {
12
+ action,
13
+ action_type: actionType,
14
+ target: target || action,
15
+ action_hash: sha256(action),
16
+ target_hash: sha256(target || action),
17
+ };
18
+ }
19
+
20
+ async function enforcementRequest(requestJson, options, operation, input = {}) {
21
+ return requestJson(options, 'POST', '/v1/agent/enforcement', {
22
+ operation,
23
+ ...input,
24
+ });
25
+ }
26
+
27
+ async function issueActionPermit(requestJson, options, input) {
28
+ const binding = actionBinding(input);
29
+ return enforcementRequest(requestJson, options, 'issue', {
30
+ ...binding,
31
+ session_id: options.sessionId,
32
+ agent_id: options.agentId,
33
+ harness: options.client,
34
+ policy_mode: options.policy,
35
+ decision_id: input.decisionId || null,
36
+ gate_receipt_id: input.gateReceiptId || null,
37
+ owner_approval_receipt_id: input.ownerApproval || null,
38
+ surfaces: Array.isArray(input.surfaces) ? input.surfaces : [],
39
+ proof_requirements: Array.isArray(input.proofRequirements) ? input.proofRequirements : [],
40
+ });
41
+ }
42
+
43
+ async function verifyActionPermit(requestJson, options, input) {
44
+ const binding = actionBinding(input);
45
+ return enforcementRequest(requestJson, options, 'verify', {
46
+ ...binding,
47
+ surfaces: Array.isArray(input.surfaces) ? input.surfaces : [],
48
+ permit: input.permit,
49
+ session_id: options.sessionId,
50
+ agent_id: options.agentId,
51
+ harness: options.client,
52
+ });
53
+ }
54
+
55
+ async function closeActionPermit(requestJson, options, input) {
56
+ return enforcementRequest(requestJson, options, 'close', {
57
+ permit: input.permit,
58
+ permit_id: input.permitId || null,
59
+ decision_id: input.decisionId || null,
60
+ session_id: options.sessionId,
61
+ agent_id: options.agentId,
62
+ success: Boolean(input.success),
63
+ evidence: input.evidence || {},
64
+ });
65
+ }
66
+
67
+ async function recordEnforcementHeartbeat(requestJson, options, input = {}) {
68
+ return enforcementRequest(requestJson, options, 'heartbeat', {
69
+ session_id: options.sessionId,
70
+ agent_id: options.agentId,
71
+ harness: options.client,
72
+ sidecar_instance_id: input.sidecarInstanceId || null,
73
+ config_fingerprint: input.configFingerprint || null,
74
+ expected_hooks: input.expectedHooks || ['pre_action', 'action_result', 'outcome_closure'],
75
+ observed_hooks: input.observedHooks || ['pre_action'],
76
+ });
77
+ }
78
+
79
+ async function readEnforcementCoverage(requestJson, options) {
80
+ const query = new URLSearchParams();
81
+ if (options.agentId) query.set('agent_id', options.agentId);
82
+ return requestJson(options, 'GET', `/v1/agent/enforcement${query.size ? `?${query}` : ''}`);
83
+ }
84
+
85
+ module.exports = {
86
+ actionBinding,
87
+ sha256,
88
+ enforcementRequest,
89
+ issueActionPermit,
90
+ verifyActionPermit,
91
+ closeActionPermit,
92
+ recordEnforcementHeartbeat,
93
+ readEnforcementCoverage,
94
+ };