@getmarrow/install 0.1.51 → 0.1.52
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 +2 -2
- package/package.json +1 -1
- package/src/governed-runner.js +69 -11
- package/src/usage-telemetry.js +235 -0
package/README.md
CHANGED
|
@@ -91,9 +91,9 @@ npx @getmarrow/install controller stop
|
|
|
91
91
|
|
|
92
92
|
Persistent controller lifecycle is currently Linux-only. On macOS or Windows, activation still writes supported configuration and verifies one server-side install self-test without certifying that hooks continuously ran; 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.
|
|
93
93
|
|
|
94
|
-
## What's New in v0.1.
|
|
94
|
+
## What's New in v0.1.52
|
|
95
95
|
|
|
96
|
-
v0.1.
|
|
96
|
+
v0.1.52 keeps MCP `3.9.74` and SDK `3.7.62`, and makes generated passive runtime identity follow the current process: `MARROW_FLEET_AGENT_ID` first, then `MARROW_AGENT_ID`, with the installer-captured identity retained only as a fallback. This prevents a stale installed harness identity from overriding the current Codex, Bob, or other canonical process identity. Managed MCP config does not store an API key or shell-style credential placeholder; the owning harness inherits the key from trusted environment or secret-manager state.
|
|
97
97
|
|
|
98
98
|
The integration boundary is explicit: native Claude hooks are installed only where supported and remain cooperative/client-reported until authoritative server receipts exist; MCP tools are on demand; Codex, Grok, Gemini, and similar CLIs use the governed wrapper for consequential control; owned Node processes use the SDK passive runtime while installed; and custom hosts require a bounded event adapter. Exact package SHA/integrity can prove artifact provenance, not runtime coverage. Activation-profile delivery is authenticated `client_self_reported` telemetry with `certified_coverage: false`; it acknowledges delivery but cannot attest that a hook, wrapper, or adapter ran.
|
|
99
99
|
|
package/package.json
CHANGED
package/src/governed-runner.js
CHANGED
|
@@ -27,6 +27,7 @@ const {
|
|
|
27
27
|
buildPlan,
|
|
28
28
|
detectEnvironment,
|
|
29
29
|
} = require('./installer');
|
|
30
|
+
const { createHostUsageCapture } = require('./usage-telemetry');
|
|
30
31
|
|
|
31
32
|
const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
|
|
32
33
|
const MCP_SETUP_COMMAND = `npx -y --package=${ADAPTER_PROVENANCE.mcp.package}@${ADAPTER_PROVENANCE.mcp.version} marrow-mcp setup`;
|
|
@@ -512,11 +513,11 @@ function dataOf(json) {
|
|
|
512
513
|
return json && typeof json === 'object' && json.data && typeof json.data === 'object' ? json.data : json;
|
|
513
514
|
}
|
|
514
515
|
|
|
515
|
-
async function requestJson(options, method, route, body) {
|
|
516
|
+
async function requestJson(options, method, route, body, extraHeaders = {}) {
|
|
516
517
|
if (!options.apiKey) throw new Error('MARROW_API_KEY is required. Use --fail-open only for non-production local commands.');
|
|
517
518
|
const response = await fetch(new URL(route, options.baseUrl.replace(/\/$/, '/')), {
|
|
518
519
|
method,
|
|
519
|
-
headers: headers(options),
|
|
520
|
+
headers: { ...headers(options), ...extraHeaders },
|
|
520
521
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
521
522
|
});
|
|
522
523
|
const text = await response.text();
|
|
@@ -674,15 +675,48 @@ function printGate(decision, runtime, stream = process.stdout) {
|
|
|
674
675
|
if (runtime?.value_proof?.owner_summary) stream.write(`Value: ${runtime.value_proof.owner_summary}\n`);
|
|
675
676
|
}
|
|
676
677
|
|
|
677
|
-
function runChild(command, env = process.env) {
|
|
678
|
+
function runChild(command, env = process.env, stdout = process.stdout) {
|
|
678
679
|
return new Promise((resolve) => {
|
|
680
|
+
const usageCapture = createHostUsageCapture(command);
|
|
679
681
|
const child = spawn(command[0], command.slice(1), {
|
|
680
|
-
stdio: 'inherit',
|
|
682
|
+
stdio: usageCapture.supported ? ['inherit', 'pipe', 'inherit'] : 'inherit',
|
|
681
683
|
shell: false,
|
|
682
684
|
env,
|
|
683
685
|
});
|
|
684
|
-
|
|
685
|
-
|
|
686
|
+
let closed = null;
|
|
687
|
+
let pendingWrites = 0;
|
|
688
|
+
let settled = false;
|
|
689
|
+
const settle = () => {
|
|
690
|
+
if (settled || !closed || pendingWrites > 0) return;
|
|
691
|
+
settled = true;
|
|
692
|
+
resolve({
|
|
693
|
+
...closed,
|
|
694
|
+
usageCaptureSupported: usageCapture.supported,
|
|
695
|
+
modelUsage: usageCapture.finish(),
|
|
696
|
+
});
|
|
697
|
+
};
|
|
698
|
+
if (usageCapture.supported && child.stdout) {
|
|
699
|
+
child.stdout.on('data', (chunk) => {
|
|
700
|
+
usageCapture.write(chunk);
|
|
701
|
+
pendingWrites += 1;
|
|
702
|
+
const ready = stdout.write(chunk, () => {
|
|
703
|
+
pendingWrites -= 1;
|
|
704
|
+
settle();
|
|
705
|
+
});
|
|
706
|
+
if (!ready) {
|
|
707
|
+
child.stdout.pause();
|
|
708
|
+
stdout.once('drain', () => child.stdout?.resume());
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
child.on('error', (error) => {
|
|
713
|
+
closed = { exitCode: 127, error };
|
|
714
|
+
settle();
|
|
715
|
+
});
|
|
716
|
+
child.on('close', (code, signal) => {
|
|
717
|
+
closed = { exitCode: code ?? 1, signal: signal || null };
|
|
718
|
+
settle();
|
|
719
|
+
});
|
|
686
720
|
});
|
|
687
721
|
}
|
|
688
722
|
|
|
@@ -718,7 +752,7 @@ async function createDecision(options, action, type, target, surfaces) {
|
|
|
718
752
|
});
|
|
719
753
|
}
|
|
720
754
|
|
|
721
|
-
async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId) {
|
|
755
|
+
async function commitOutcome(options, decisionId, success, outcome, proof, gateReceiptId, modelUsage = null) {
|
|
722
756
|
const body = {
|
|
723
757
|
decision_id: decisionId,
|
|
724
758
|
success,
|
|
@@ -727,10 +761,16 @@ async function commitOutcome(options, decisionId, success, outcome, proof, gateR
|
|
|
727
761
|
source_meta: sourceMeta(options, 'commit', { action: outcome }),
|
|
728
762
|
};
|
|
729
763
|
if (gateReceiptId) body.gate_receipt_id = gateReceiptId;
|
|
730
|
-
|
|
764
|
+
if (modelUsage) body.model_usage = modelUsage;
|
|
765
|
+
const commitHeaders = modelUsage
|
|
766
|
+
? { 'Idempotency-Key': `marrow-run-${crypto.createHash('sha256')
|
|
767
|
+
.update(`${options.agentId}\u0000${options.sessionId}\u0000${decisionId}`)
|
|
768
|
+
.digest('hex')}` }
|
|
769
|
+
: {};
|
|
770
|
+
return requestJson(options, 'POST', '/v1/agent/commit', body, commitHeaders);
|
|
731
771
|
}
|
|
732
772
|
|
|
733
|
-
async function runGoverned(parsed) {
|
|
773
|
+
async function runGoverned(parsed, execution = {}) {
|
|
734
774
|
const { options, childCommand } = parsed;
|
|
735
775
|
const commandText = redactedCommand(childCommand);
|
|
736
776
|
const action = options.action ? redact(options.action) : commandText;
|
|
@@ -816,7 +856,7 @@ async function runGoverned(parsed) {
|
|
|
816
856
|
}
|
|
817
857
|
|
|
818
858
|
const childEnv = scopedExecutionEnv(permitVerified ? actionPermit : null);
|
|
819
|
-
const child = await runChild(childCommand, childEnv);
|
|
859
|
+
const child = await runChild(childCommand, childEnv, execution.stdout || process.stdout);
|
|
820
860
|
const success = child.exitCode === 0;
|
|
821
861
|
const proof = defaultProof({ options, action, childCommand, exitCode: child.exitCode, success });
|
|
822
862
|
const outcome = success
|
|
@@ -826,7 +866,15 @@ async function runGoverned(parsed) {
|
|
|
826
866
|
let commit = null;
|
|
827
867
|
if (decisionId) {
|
|
828
868
|
try {
|
|
829
|
-
commit = await commitOutcome(
|
|
869
|
+
commit = await commitOutcome(
|
|
870
|
+
options,
|
|
871
|
+
decisionId,
|
|
872
|
+
success,
|
|
873
|
+
outcome,
|
|
874
|
+
proof,
|
|
875
|
+
decision?.receiptId || '',
|
|
876
|
+
child.modelUsage,
|
|
877
|
+
);
|
|
830
878
|
} catch (error) {
|
|
831
879
|
process.stderr.write(`Marrow outcome commit failed: ${error.message}\n`);
|
|
832
880
|
}
|
|
@@ -861,6 +909,16 @@ async function runGoverned(parsed) {
|
|
|
861
909
|
permit_id: actionPermit?.permit_id || null,
|
|
862
910
|
permit_verified: permitVerified,
|
|
863
911
|
permit_closed: Boolean(permitClosed),
|
|
912
|
+
usage_capture: {
|
|
913
|
+
supported: child.usageCaptureSupported === true,
|
|
914
|
+
observed: Boolean(child.modelUsage),
|
|
915
|
+
source: child.modelUsage?.source || null,
|
|
916
|
+
evidence: child.modelUsage
|
|
917
|
+
? 'accepted_host_reported_usage'
|
|
918
|
+
: child.usageCaptureSupported
|
|
919
|
+
? 'no_valid_host_reported_usage'
|
|
920
|
+
: 'unsupported_child_command',
|
|
921
|
+
},
|
|
864
922
|
};
|
|
865
923
|
}
|
|
866
924
|
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const { StringDecoder } = require('node:string_decoder');
|
|
3
|
+
|
|
4
|
+
const MAX_TOKEN_COUNT = 1_000_000_000;
|
|
5
|
+
const MAX_USAGE_EVENT_BYTES = 4_096;
|
|
6
|
+
const CODEX_USAGE_KEYS = new Set([
|
|
7
|
+
'cached_input_tokens',
|
|
8
|
+
'input_tokens',
|
|
9
|
+
'output_tokens',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function isBoundedTokenCount(value) {
|
|
13
|
+
return Number.isSafeInteger(value) && value >= 0 && value <= MAX_TOKEN_COUNT;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function exactKeys(value, allowed) {
|
|
17
|
+
const keys = Object.keys(value);
|
|
18
|
+
return keys.length === allowed.size && keys.every((key) => allowed.has(key));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeCodexTurnUsage(event) {
|
|
22
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) return null;
|
|
23
|
+
if (!exactKeys(event, new Set(['type', 'usage'])) || event.type !== 'turn.completed') return null;
|
|
24
|
+
|
|
25
|
+
const usage = event.usage;
|
|
26
|
+
if (!usage || typeof usage !== 'object' || Array.isArray(usage)) return null;
|
|
27
|
+
if (!exactKeys(usage, CODEX_USAGE_KEYS)) return null;
|
|
28
|
+
|
|
29
|
+
const inputTokens = usage.input_tokens;
|
|
30
|
+
const outputTokens = usage.output_tokens;
|
|
31
|
+
const cachedTokens = usage.cached_input_tokens;
|
|
32
|
+
if (![inputTokens, outputTokens, cachedTokens].every(isBoundedTokenCount)) return null;
|
|
33
|
+
if (cachedTokens > inputTokens) return null;
|
|
34
|
+
|
|
35
|
+
const totalTokens = inputTokens + outputTokens;
|
|
36
|
+
if (!Number.isSafeInteger(totalTokens) || totalTokens > MAX_TOKEN_COUNT) return null;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
provider: 'openai',
|
|
40
|
+
input_tokens: inputTokens,
|
|
41
|
+
output_tokens: outputTokens,
|
|
42
|
+
cached_tokens: cachedTokens,
|
|
43
|
+
total_tokens: totalTokens,
|
|
44
|
+
source: 'codex_exec_jsonl',
|
|
45
|
+
marrow_intervention: 'governed_runner_usage_capture',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isCodexJsonExecution(command) {
|
|
50
|
+
if (!Array.isArray(command) || command.length < 3) return false;
|
|
51
|
+
const executable = path.basename(String(command[0] || '')).toLowerCase().replace(/\.exe$/, '');
|
|
52
|
+
const execArgs = command.slice(2);
|
|
53
|
+
const terminatorIndex = execArgs.indexOf('--');
|
|
54
|
+
const optionArgs = terminatorIndex === -1 ? execArgs : execArgs.slice(0, terminatorIndex);
|
|
55
|
+
return executable === 'codex'
|
|
56
|
+
&& command[1] === 'exec'
|
|
57
|
+
&& optionArgs.includes('--json');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createCompletedTypeScanner() {
|
|
61
|
+
let depth = 0;
|
|
62
|
+
let inString = false;
|
|
63
|
+
let escaped = false;
|
|
64
|
+
let role = 'other';
|
|
65
|
+
let token = '';
|
|
66
|
+
let tokenInvalid = false;
|
|
67
|
+
let lastSignificant = '';
|
|
68
|
+
let expectTypeColon = false;
|
|
69
|
+
let expectTypeValue = false;
|
|
70
|
+
let completed = false;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
write(character) {
|
|
74
|
+
if (inString) {
|
|
75
|
+
if (escaped) {
|
|
76
|
+
escaped = false;
|
|
77
|
+
tokenInvalid = role !== 'other';
|
|
78
|
+
} else if (character === '\\') {
|
|
79
|
+
escaped = true;
|
|
80
|
+
} else if (character === '"') {
|
|
81
|
+
inString = false;
|
|
82
|
+
if (role === 'root_key') {
|
|
83
|
+
expectTypeColon = !tokenInvalid && token === 'type';
|
|
84
|
+
} else if (role === 'type_value') {
|
|
85
|
+
if (!tokenInvalid && token === 'turn.completed') completed = true;
|
|
86
|
+
expectTypeValue = false;
|
|
87
|
+
}
|
|
88
|
+
role = 'other';
|
|
89
|
+
token = '';
|
|
90
|
+
tokenInvalid = false;
|
|
91
|
+
lastSignificant = '"';
|
|
92
|
+
} else if (role !== 'other') {
|
|
93
|
+
if (token.length < 32) token += character;
|
|
94
|
+
else tokenInvalid = true;
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (/\s/.test(character)) return;
|
|
100
|
+
if (character === '"') {
|
|
101
|
+
role = depth === 1 && (lastSignificant === '{' || lastSignificant === ',')
|
|
102
|
+
? 'root_key'
|
|
103
|
+
: depth === 1 && expectTypeValue
|
|
104
|
+
? 'type_value'
|
|
105
|
+
: 'other';
|
|
106
|
+
token = '';
|
|
107
|
+
tokenInvalid = false;
|
|
108
|
+
inString = true;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (character === '{' || character === '[') {
|
|
112
|
+
depth += 1;
|
|
113
|
+
lastSignificant = character;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (character === '}' || character === ']') {
|
|
117
|
+
depth = Math.max(0, depth - 1);
|
|
118
|
+
lastSignificant = character;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (depth === 1 && expectTypeColon) {
|
|
122
|
+
expectTypeColon = false;
|
|
123
|
+
expectTypeValue = character === ':';
|
|
124
|
+
} else if (depth === 1 && expectTypeValue) {
|
|
125
|
+
expectTypeValue = false;
|
|
126
|
+
}
|
|
127
|
+
if (depth === 1 && character === ',') {
|
|
128
|
+
expectTypeColon = false;
|
|
129
|
+
expectTypeValue = false;
|
|
130
|
+
}
|
|
131
|
+
lastSignificant = character;
|
|
132
|
+
},
|
|
133
|
+
completed() {
|
|
134
|
+
return completed;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createCodexJsonlUsageCollector() {
|
|
140
|
+
const decoder = new StringDecoder('utf8');
|
|
141
|
+
let line = '';
|
|
142
|
+
let discardLine = false;
|
|
143
|
+
let acceptedUsage = null;
|
|
144
|
+
let usageEventCount = 0;
|
|
145
|
+
let invalidUsageEvent = false;
|
|
146
|
+
let typeScanner = createCompletedTypeScanner();
|
|
147
|
+
|
|
148
|
+
const resetLine = () => {
|
|
149
|
+
line = '';
|
|
150
|
+
discardLine = false;
|
|
151
|
+
typeScanner = createCompletedTypeScanner();
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const finishLine = () => {
|
|
155
|
+
if (discardLine || !line.trim()) {
|
|
156
|
+
if (discardLine && typeScanner.completed()) invalidUsageEvent = true;
|
|
157
|
+
resetLine();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
let parsed = null;
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(line);
|
|
163
|
+
} catch {
|
|
164
|
+
if (typeScanner.completed()) invalidUsageEvent = true;
|
|
165
|
+
resetLine();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || parsed.type !== 'turn.completed') {
|
|
169
|
+
resetLine();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
usageEventCount += 1;
|
|
174
|
+
const normalized = normalizeCodexTurnUsage(parsed);
|
|
175
|
+
if (!normalized || usageEventCount !== 1) invalidUsageEvent = true;
|
|
176
|
+
else acceptedUsage = normalized;
|
|
177
|
+
resetLine();
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const consume = (text) => {
|
|
181
|
+
for (const character of text) {
|
|
182
|
+
if (character === '\n') {
|
|
183
|
+
finishLine();
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
typeScanner.write(character);
|
|
187
|
+
if (discardLine) continue;
|
|
188
|
+
if (line.length >= MAX_USAGE_EVENT_BYTES) {
|
|
189
|
+
discardLine = true;
|
|
190
|
+
line = '';
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
line += character;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
write(chunk) {
|
|
199
|
+
consume(decoder.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
200
|
+
},
|
|
201
|
+
finish() {
|
|
202
|
+
consume(decoder.end());
|
|
203
|
+
if (line || discardLine) finishLine();
|
|
204
|
+
return invalidUsageEvent || usageEventCount !== 1 ? null : acceptedUsage;
|
|
205
|
+
},
|
|
206
|
+
bufferedBytes() {
|
|
207
|
+
return Buffer.byteLength(line, 'utf8');
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function createHostUsageCapture(command) {
|
|
213
|
+
if (!isCodexJsonExecution(command)) {
|
|
214
|
+
return {
|
|
215
|
+
supported: false,
|
|
216
|
+
write() {},
|
|
217
|
+
finish() { return null; },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
const collector = createCodexJsonlUsageCollector();
|
|
221
|
+
return {
|
|
222
|
+
supported: true,
|
|
223
|
+
write: (chunk) => collector.write(chunk),
|
|
224
|
+
finish: () => collector.finish(),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
MAX_TOKEN_COUNT,
|
|
230
|
+
MAX_USAGE_EVENT_BYTES,
|
|
231
|
+
createCodexJsonlUsageCollector,
|
|
232
|
+
createHostUsageCapture,
|
|
233
|
+
isCodexJsonExecution,
|
|
234
|
+
normalizeCodexTurnUsage,
|
|
235
|
+
};
|