deadbyte-mcp 0.15.13 → 0.15.15
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/MANIFEST.SHA256 +20 -14
- package/PROVENANCE.json +1 -1
- package/README.txt +5 -5
- package/RELEASE.json +53 -53
- package/package.json +1 -1
- package/scripts/npm-release-r47.mjs +21 -0
- package/src/coding-task-runtime.mjs +6 -1
- package/src/observation-output-contracts.mjs +9 -8
- package/src/observation-runtime.mjs +47 -17
- package/src/operation-history.mjs +95 -0
- package/src/operation-lifecycle.mjs +63 -0
- package/src/process-output-contracts.mjs +24 -1
- package/src/process-readiness.mjs +120 -0
- package/src/process-runtime.mjs +178 -6
- package/src/release-slot.mjs +294 -293
- package/src/version.mjs +1 -1
- package/test/observation-runtime.test.mjs +28 -0
- package/test/operation-history.test.mjs +61 -0
- package/test/operation-lifecycle.test.mjs +72 -0
- package/test/process-readiness.test.mjs +32 -0
- package/test/process-runtime.test.mjs +89 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { createOperationHistory, operationHistoryReceipt } from './operation-history.mjs';
|
|
3
|
+
|
|
4
|
+
export const OPERATION_LIFECYCLE_SCHEMA='deadbyte.operation-lifecycle.v1';
|
|
5
|
+
const TERMINAL=new Set(['success','failure','cancelled','timed_out']);
|
|
6
|
+
const ACTIVE=new Set(['start','running','cancel_requested','canceling','output','error']);
|
|
7
|
+
const ALLOWED=new Map([
|
|
8
|
+
['start',new Set(['running','cancel_requested','output','error','success','failure','cancelled','timed_out'])],
|
|
9
|
+
['running',new Set(['running','cancel_requested','canceling','output','error','success','failure','cancelled','timed_out'])],
|
|
10
|
+
['cancel_requested',new Set(['canceling','cancelled','timed_out','output','error'])],
|
|
11
|
+
['canceling',new Set(['cancelled','timed_out','output','error'])],
|
|
12
|
+
['output',new Set(['running','cancel_requested','canceling','output','error','success','failure','cancelled','timed_out'])],
|
|
13
|
+
['error',new Set(['running','cancel_requested','canceling','success','failure','cancelled','timed_out','error'])],
|
|
14
|
+
['success',new Set()], ['failure',new Set()], ['cancelled',new Set()], ['timed_out',new Set()]
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export function createOperationLifecycle({operationId=randomBytes(32).toString('hex'),clockMs=()=>Date.now(),maxEvents=1024,maxBytes=1024*1024}={}){
|
|
18
|
+
const history=createOperationHistory({operationId,clockMs,maxEvents,maxBytes});
|
|
19
|
+
let state='new';
|
|
20
|
+
let terminalSequence=null;
|
|
21
|
+
let terminalEventHash=null;
|
|
22
|
+
let terminalType=null;
|
|
23
|
+
|
|
24
|
+
function emit(type,payload={}){
|
|
25
|
+
if(typeof type!=='string'||(!ACTIVE.has(type)&&!TERMINAL.has(type))) throw new Error('invalid lifecycle event');
|
|
26
|
+
if(terminalType) {
|
|
27
|
+
if(TERMINAL.has(type)) throw new Error('terminal transition already committed');
|
|
28
|
+
throw new Error('late operation event rejected after terminal');
|
|
29
|
+
}
|
|
30
|
+
if(type==='start' && state!=='new') throw new Error('duplicate operation start');
|
|
31
|
+
const allowed=state==='new' ? new Set(['start']) : ALLOWED.get(state);
|
|
32
|
+
if(!allowed?.has(type)) throw new Error('invalid operation transition '+state+' -> '+type);
|
|
33
|
+
const event=history.append(type,payload);
|
|
34
|
+
state=type;
|
|
35
|
+
if(TERMINAL.has(type)){ terminalSequence=event.sequence; terminalEventHash=event.event_hash; terminalType=type; }
|
|
36
|
+
return Object.freeze({schema:OPERATION_LIFECYCLE_SCHEMA,operation_id:operationId,state,event});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function terminal(type,payload={}) {
|
|
40
|
+
if(!TERMINAL.has(type)) throw new Error('terminal type required');
|
|
41
|
+
return emit(type,payload);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function snapshot(){
|
|
45
|
+
const verified=history.verify();
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
schema:OPERATION_LIFECYCLE_SCHEMA,operation_id:operationId,state,
|
|
48
|
+
terminal:{type:terminalType,sequence:terminalSequence,event_hash:terminalEventHash},
|
|
49
|
+
history_head_sha256:verified.head_event_hash,
|
|
50
|
+
next_sequence:verified.next_sequence,
|
|
51
|
+
overflow:verified.overflow
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function receipt(){
|
|
56
|
+
if(!terminalType) throw new Error('terminal receipt unavailable before terminal transition');
|
|
57
|
+
const h=operationHistoryReceipt(history);
|
|
58
|
+
if(h.next_sequence!==terminalSequence+1 || h.head_event_hash!==terminalEventHash) throw new Error('terminal receipt/history mismatch');
|
|
59
|
+
return Object.freeze({...h,terminal_type:terminalType,terminal_sequence:terminalSequence,terminal_event_hash:terminalEventHash});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Object.freeze({operationId,history,emit,terminal,snapshot,receipt,isTerminal:()=>Boolean(terminalType)});
|
|
63
|
+
}
|
|
@@ -4,6 +4,17 @@ import { structuredToolResult } from './output-contracts.mjs';
|
|
|
4
4
|
const hex64 = z.string().regex(/^[0-9a-f]{64}$/);
|
|
5
5
|
const digits = z.string().regex(/^[0-9]+$/);
|
|
6
6
|
const processState = z.enum(['running','exited','terminated']);
|
|
7
|
+
const operationSnapshot = z.strictObject({
|
|
8
|
+
schema:z.literal('deadbyte.operation-lifecycle.v1'), operation_id:hex64,
|
|
9
|
+
state:z.string().min(1).max(32),
|
|
10
|
+
terminal:z.strictObject({type:z.enum(['success','failure','cancelled','timed_out']).nullable(),sequence:z.number().int().positive().nullable(),event_hash:hex64.nullable()}),
|
|
11
|
+
history_head_sha256:hex64,next_sequence:z.number().int().positive(),overflow:z.object({}).passthrough()
|
|
12
|
+
});
|
|
13
|
+
const operationReceipt = z.strictObject({
|
|
14
|
+
schema:z.literal('deadbyte.operation-terminal-receipt.v1'),operation_id:hex64,session_id:hex64,
|
|
15
|
+
terminal:z.object({}).passthrough(),root_id:z.string().min(1).max(64),policy_sha256:hex64,
|
|
16
|
+
key_id:z.string().min(1).max(128),signature:z.string().min(1).max(512)
|
|
17
|
+
});
|
|
7
18
|
const termination = z.strictObject({
|
|
8
19
|
deadline_exceeded:z.boolean(), cancellation_requested:z.boolean(), physical_termination:z.boolean(), verified:z.boolean(),
|
|
9
20
|
terminal_semantics:z.string().min(1).max(64)
|
|
@@ -11,6 +22,12 @@ const termination = z.strictObject({
|
|
|
11
22
|
const deadline = z.strictObject({
|
|
12
23
|
schema:z.literal('deadbyte.deadline.v1'), id:hex64.nullable(), created_at_ms:z.number(), timeout_ms:z.number().int().positive().nullable(), deadline_ms:z.number().nullable()
|
|
13
24
|
});
|
|
25
|
+
const readiness = z.strictObject({
|
|
26
|
+
state:z.enum(['running','ready_for_input','finished']),
|
|
27
|
+
evidence:z.string().min(1).max(64),
|
|
28
|
+
prompt:z.strictObject({profile:z.string().min(1).max(32),prompt:z.string().min(1).max(64)}).nullable(),
|
|
29
|
+
exit_code:z.number().int().nullable()
|
|
30
|
+
});
|
|
14
31
|
const streamCoverage = z.strictObject({
|
|
15
32
|
logical_first_seq:z.number().int().min(1), logical_last_seq:z.number().int().min(0),
|
|
16
33
|
retained_first_seq:z.number().int().min(1), retained_last_seq:z.number().int().min(0),
|
|
@@ -40,7 +57,13 @@ export const processSessionRecordSchema = z.strictObject({
|
|
|
40
57
|
stream_coverage: streamCoverage.nullable(),
|
|
41
58
|
termination: termination.nullable(),
|
|
42
59
|
deadline: deadline.nullable(),
|
|
43
|
-
|
|
60
|
+
readiness: readiness.optional().nullable(),
|
|
61
|
+
journal_head_sha256: hex64,
|
|
62
|
+
operation_id: hex64.nullable().optional(),
|
|
63
|
+
operation: operationSnapshot.nullable().optional(),
|
|
64
|
+
operation_history_head_sha256: hex64.nullable().optional(),
|
|
65
|
+
operation_receipt: operationReceipt.nullable().optional(),
|
|
66
|
+
operation_history_error: z.string().max(4096).nullable().optional()
|
|
44
67
|
});
|
|
45
68
|
|
|
46
69
|
function resultWithSession(okStatus = 'ok') {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const PROMPTS = Object.freeze({
|
|
2
|
+
python: ['>>> ', '... '],
|
|
3
|
+
node: ['> ', '... '],
|
|
4
|
+
powershell: ['PS> ', 'PS C:\\', '>> '],
|
|
5
|
+
cmd: ['> ', 'More? '],
|
|
6
|
+
shell: ['$ ', '# ', '% '],
|
|
7
|
+
mysql: ['mysql> ', ' -> '],
|
|
8
|
+
postgres: ['=# ', '-# '],
|
|
9
|
+
redis: ['redis> ']
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const COMPLETION = [
|
|
13
|
+
/(?:^|\n)Process finished(?:\r?\n|$)/i,
|
|
14
|
+
/(?:^|\n)Command completed(?:\r?\n|$)/i,
|
|
15
|
+
/(?:^|\n)\[Process completed\]/i,
|
|
16
|
+
/(?:^|\n)Program terminated(?:\r?\n|$)/i,
|
|
17
|
+
/(?:^|\n)Exit code:\s*-?\d+/i
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const ERROR_TERMINAL = [
|
|
21
|
+
/^\s*SyntaxError:/m,
|
|
22
|
+
/^\s*TypeError:/m,
|
|
23
|
+
/^\s*ReferenceError:/m,
|
|
24
|
+
/^\s*NameError:/m,
|
|
25
|
+
/^\s*Traceback/m,
|
|
26
|
+
/^\s*Unhandled exception/m
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const stripAnsi = value => String(value ?? '').replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '');
|
|
30
|
+
|
|
31
|
+
export function normalizeProcessText(value) {
|
|
32
|
+
return stripAnsi(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function detectPrompt(text, profiles = PROMPTS) {
|
|
36
|
+
const normalized = normalizeProcessText(text);
|
|
37
|
+
const lastLine = normalized.split('\n').at(-1) ?? '';
|
|
38
|
+
for (const [profile, prompts] of Object.entries(profiles)) {
|
|
39
|
+
for (const prompt of prompts) {
|
|
40
|
+
if (lastLine.endsWith(prompt) || lastLine === prompt.trimEnd()) {
|
|
41
|
+
return { profile, prompt };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function analyzeReadiness({stdout='', stderr='', state='running', exitCode=null} = {}) {
|
|
49
|
+
const out = normalizeProcessText(stdout);
|
|
50
|
+
const err = normalizeProcessText(stderr);
|
|
51
|
+
if (state === 'exited' || state === 'terminated') {
|
|
52
|
+
return Object.freeze({ state: 'finished', evidence: 'process_terminal', prompt: null, exit_code: exitCode });
|
|
53
|
+
}
|
|
54
|
+
const prompt = detectPrompt(out);
|
|
55
|
+
if (prompt) {
|
|
56
|
+
return Object.freeze({ state: 'ready_for_input', evidence: 'prompt', prompt, exit_code: null });
|
|
57
|
+
}
|
|
58
|
+
if (COMPLETION.some(pattern => pattern.test(out)) || ERROR_TERMINAL.some(pattern => pattern.test(err || out))) {
|
|
59
|
+
return Object.freeze({ state: 'finished', evidence: 'terminal_output', prompt: null, exit_code: exitCode });
|
|
60
|
+
}
|
|
61
|
+
if (out.length === 0 && err.length === 0) {
|
|
62
|
+
return Object.freeze({ state: 'running', evidence: 'no_output', prompt: null, exit_code: null });
|
|
63
|
+
}
|
|
64
|
+
return Object.freeze({ state: 'running', evidence: 'output_without_terminal_marker', prompt: null, exit_code: null });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function lineEndingProfile(text) {
|
|
68
|
+
const value = String(text ?? '');
|
|
69
|
+
let crlf = 0, lf = 0, cr = 0;
|
|
70
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
71
|
+
if (value[i] === '\r') {
|
|
72
|
+
if (value[i + 1] === '\n') { crlf += 1; i += 1; } else cr += 1;
|
|
73
|
+
} else if (value[i] === '\n') lf += 1;
|
|
74
|
+
}
|
|
75
|
+
const total = crlf + lf + cr;
|
|
76
|
+
const styles = [crlf > 0, lf > 0, cr > 0].filter(Boolean).length;
|
|
77
|
+
const style = crlf >= lf && crlf >= cr && crlf > 0 ? '\r\n' : lf > 0 ? '\n' : cr > 0 ? '\r' : null;
|
|
78
|
+
return Object.freeze({ style, crlf, lf, cr, total, mixed: styles > 1 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function normalizeLineEndings(text, target) {
|
|
82
|
+
const lf = String(text ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
83
|
+
if (target === '\r\n') return lf.replace(/\n/g, '\r\n');
|
|
84
|
+
if (target === '\r') return lf.replace(/\n/g, '\r');
|
|
85
|
+
return lf;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createReadinessTracker({maxEvidenceBytes=16384}={}) {
|
|
89
|
+
if (!Number.isInteger(maxEvidenceBytes) || maxEvidenceBytes < 256) {
|
|
90
|
+
throw new Error('maxEvidenceBytes must be >= 256');
|
|
91
|
+
}
|
|
92
|
+
let stdout = '';
|
|
93
|
+
let stderr = '';
|
|
94
|
+
let sequence = 0;
|
|
95
|
+
let last = analyzeReadiness();
|
|
96
|
+
|
|
97
|
+
const append = (channel, chunk) => {
|
|
98
|
+
const value = normalizeProcessText(chunk);
|
|
99
|
+
if (channel === 'stderr') stderr = (stderr + value).slice(-maxEvidenceBytes);
|
|
100
|
+
else stdout = (stdout + value).slice(-maxEvidenceBytes);
|
|
101
|
+
sequence += 1;
|
|
102
|
+
last = analyzeReadiness({stdout, stderr});
|
|
103
|
+
return Object.freeze({sequence, channel, readiness: last});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const updateTerminal = (state, exitCode) => {
|
|
107
|
+
last = analyzeReadiness({stdout, stderr, state, exitCode});
|
|
108
|
+
sequence += 1;
|
|
109
|
+
return Object.freeze({sequence, readiness: last});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const snapshot = () => Object.freeze({
|
|
113
|
+
sequence,
|
|
114
|
+
readiness: last,
|
|
115
|
+
stdout_tail: stdout.slice(-2048),
|
|
116
|
+
stderr_tail: stderr.slice(-2048)
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return Object.freeze({append, updateTerminal, snapshot});
|
|
120
|
+
}
|
package/src/process-runtime.mjs
CHANGED
|
@@ -2,12 +2,14 @@ import { createHash, randomBytes, sign as cryptoSign, verify as cryptoVerify } f
|
|
|
2
2
|
import { appendFile, lstat, mkdir, readFile, readdir, realpath, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
+
import { createOperationLifecycle } from './operation-lifecycle.mjs';
|
|
5
6
|
import { canonicalJson } from './canonical-json.mjs';
|
|
6
7
|
import { isInside, validatePortableRelativePath } from './path-policy.mjs';
|
|
7
8
|
import { loadPrivateSigningKey, loadPublicVerifyKey } from './trust.mjs';
|
|
8
9
|
import { assertProcessArmed, recheckProcessAuthority } from './process-policy.mjs';
|
|
9
10
|
import { createSequenceStream } from './workload-substrate.mjs';
|
|
10
11
|
import { classifyTermination, composeAbortSignal, createCancellationToken, createDeadline } from './deadline-cancellation.mjs';
|
|
12
|
+
import { createReadinessTracker } from './process-readiness.mjs';
|
|
11
13
|
|
|
12
14
|
const EVENT_SCHEMA = 'deadbyte.process-event.v1';
|
|
13
15
|
const EVENT_DOMAIN = Buffer.from('DEADBYTE-PROCESS-EVENT-V1\0', 'utf8');
|
|
@@ -18,6 +20,19 @@ const START_TIMEOUT_MS = 8000;
|
|
|
18
20
|
const TERMINAL_RECOVERY_SCHEMA = 'deadbyte.process-terminal.v1';
|
|
19
21
|
|
|
20
22
|
const sha256Bytes = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
23
|
+
function textToOperationRecords(text){
|
|
24
|
+
const records=text.split(/\r?\n/).filter(Boolean).map(line=>JSON.parse(line));
|
|
25
|
+
let previous=ZERO_SHA256;
|
|
26
|
+
for(let i=0;i<records.length;i++){
|
|
27
|
+
const record=records[i];
|
|
28
|
+
const {event_hash:eventHash,payload,...body}=record;
|
|
29
|
+
if(body.sequence!==i+1||body.previous_event_hash!==previous||
|
|
30
|
+
body.payload_digest!==sha256Bytes(Buffer.from(canonicalJson(payload ?? {}),'utf8'))||
|
|
31
|
+
eventHash!==sha256Bytes(Buffer.from(canonicalJson(body),'utf8'))) throw new Error('operation history chain mismatch');
|
|
32
|
+
previous=eventHash;
|
|
33
|
+
}
|
|
34
|
+
return records;
|
|
35
|
+
}
|
|
21
36
|
const nowUtc = () => new Date().toISOString();
|
|
22
37
|
|
|
23
38
|
const STANDARD_PATHEXT = '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC';
|
|
@@ -81,7 +96,13 @@ function publicSession(session) {
|
|
|
81
96
|
stream_coverage: stream?.coverage() ?? null,
|
|
82
97
|
termination: session.termination ?? null,
|
|
83
98
|
deadline: session.deadline ?? null,
|
|
84
|
-
|
|
99
|
+
readiness: session.readiness?.snapshot().readiness ?? null,
|
|
100
|
+
journal_head_sha256: session.journal_head_sha256,
|
|
101
|
+
operation_id: session.operation_lifecycle?.operationId ?? null,
|
|
102
|
+
operation: session.operation_lifecycle?.snapshot() ?? null,
|
|
103
|
+
operation_history_head_sha256: session.operation_history_head_sha256 ?? null,
|
|
104
|
+
operation_receipt: session.operation_receipt ?? null,
|
|
105
|
+
operation_history_error: session.operation_history_error ?? null
|
|
85
106
|
};
|
|
86
107
|
}
|
|
87
108
|
function retainedSlice(session, begin = 0, length = session.retained_output_bytes - begin) {
|
|
@@ -173,6 +194,75 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
173
194
|
verifying: () => (verifyPromise ??= loadPublicVerifyKey(verifyKeyPath))
|
|
174
195
|
};
|
|
175
196
|
|
|
197
|
+
async function persistOperationEvent(session, result){
|
|
198
|
+
const recordPath=session.operation_history_path;
|
|
199
|
+
await appendFile(recordPath, canonicalJson(result.event)+'\n', 'utf8');
|
|
200
|
+
session.operation_history_head_sha256=result.event.event_hash;
|
|
201
|
+
session.operation_sequence=result.event.sequence;
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
async function recordOperationEvent(session,type,payload={}){
|
|
205
|
+
const result=session.operation_lifecycle.emit(type,payload);
|
|
206
|
+
return persistOperationEvent(session,result);
|
|
207
|
+
}
|
|
208
|
+
function queueOperationEvent(session,type,payload={}){
|
|
209
|
+
const next=session.operation_event_chain.then(async ()=>{
|
|
210
|
+
try { return await recordOperationEvent(session,type,payload); }
|
|
211
|
+
catch(error){ session.operation_history_error=String(error?.message ?? error); throw error; }
|
|
212
|
+
});
|
|
213
|
+
session.operation_event_chain=next.catch(()=>undefined);
|
|
214
|
+
return next;
|
|
215
|
+
}
|
|
216
|
+
async function createSignedOperationReceipt(session){
|
|
217
|
+
const body={
|
|
218
|
+
schema:'deadbyte.operation-terminal-receipt.v1',
|
|
219
|
+
operation_id:session.operation_lifecycle.operationId,
|
|
220
|
+
session_id:session.session_id,
|
|
221
|
+
terminal:session.operation_lifecycle.receipt(),
|
|
222
|
+
root_id:session.root_id,
|
|
223
|
+
policy_sha256:runtime.policy.policy_sha256
|
|
224
|
+
};
|
|
225
|
+
const signing=await runtime.signing();
|
|
226
|
+
const canonical=Buffer.from(canonicalJson(body),'utf8');
|
|
227
|
+
const signature=cryptoSign(null,Buffer.concat([Buffer.from('DEADBYTE-OPERATION-RECEIPT-V1\\0','utf8'),canonical]),signing.privateKey).toString('base64');
|
|
228
|
+
const receipt={...body,key_id:signing.keyId,signature};
|
|
229
|
+
await writeFile(session.operation_receipt_path,canonicalJson(receipt)+'\n',{flag:'wx'});
|
|
230
|
+
session.operation_receipt=receipt;
|
|
231
|
+
return receipt;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function verifyOperationEvidence({sessionId}){
|
|
235
|
+
const session=await sessionFor(sessionId);
|
|
236
|
+
const text=await readFile(session.operation_history_path,'utf8');
|
|
237
|
+
const records=text.split(/\r?\n/).filter(Boolean).map(line=>JSON.parse(line));
|
|
238
|
+
let previous=ZERO_SHA256;
|
|
239
|
+
for(let i=0;i<records.length;i++){
|
|
240
|
+
const record=records[i];
|
|
241
|
+
const {event_hash:eventHash,payload,...body}=record;
|
|
242
|
+
if(body.operation_id!==session.operation_lifecycle.operationId || body.sequence!==i+1 || body.previous_event_hash!==previous){
|
|
243
|
+
throw new Error('operation history continuity mismatch');
|
|
244
|
+
}
|
|
245
|
+
const payloadDigest=sha256Bytes(Buffer.from(canonicalJson(payload ?? {}),'utf8'));
|
|
246
|
+
if(body.payload_digest!==payloadDigest || eventHash!==sha256Bytes(Buffer.from(canonicalJson(body),'utf8'))){
|
|
247
|
+
throw new Error('operation history hash mismatch');
|
|
248
|
+
}
|
|
249
|
+
previous=eventHash;
|
|
250
|
+
}
|
|
251
|
+
if(previous!==session.operation_history_head_sha256) throw new Error('operation history head mismatch');
|
|
252
|
+
const receipt=JSON.parse(await readFile(session.operation_receipt_path,'utf8'));
|
|
253
|
+
const {key_id:keyId,signature,...body}=receipt;
|
|
254
|
+
const verifying=await runtime.verifying();
|
|
255
|
+
const canonical=Buffer.from(canonicalJson(body),'utf8');
|
|
256
|
+
if(keyId!==verifying.keyId || !cryptoVerify(null,Buffer.concat([Buffer.from('DEADBYTE-OPERATION-RECEIPT-V1\\0','utf8'),canonical]),verifying.publicKey,Buffer.from(signature,'base64'))){
|
|
257
|
+
throw new Error('operation terminal receipt signature mismatch');
|
|
258
|
+
}
|
|
259
|
+
if(body.operation_id!==session.operation_lifecycle.operationId || body.session_id!==session.session_id ||
|
|
260
|
+
body.terminal?.terminal_event_hash!==previous || body.terminal?.terminal_sequence!==records.length){
|
|
261
|
+
throw new Error('operation terminal receipt/history mismatch');
|
|
262
|
+
}
|
|
263
|
+
return {ok:true,operation_id:body.operation_id,terminal_sequence:body.terminal.terminal_sequence,terminal_event_hash:previous,receipt};
|
|
264
|
+
}
|
|
265
|
+
|
|
176
266
|
async function readVerifiedJournal(sessionId, journalPath) {
|
|
177
267
|
const verifying = await runtime.verifying();
|
|
178
268
|
const text = await readFile(journalPath, 'utf8');
|
|
@@ -200,6 +290,7 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
200
290
|
async function hydrateTerminalSessions() {
|
|
201
291
|
if (hydrationPromise) return hydrationPromise;
|
|
202
292
|
hydrationPromise = (async () => {
|
|
293
|
+
const verifying = await runtime.verifying();
|
|
203
294
|
await mkdir(policy.state_dir, { recursive: true });
|
|
204
295
|
const names = (await readdir(policy.state_dir)).filter(name => /^[0-9a-f]{64}\.jsonl$/.test(name)).sort();
|
|
205
296
|
for (const name of names) {
|
|
@@ -224,8 +315,39 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
224
315
|
!SHA256_RE.test(terminal.detail.stream_sha256 ?? '') || !Number.isInteger(terminal.detail.stream_head) ||
|
|
225
316
|
!Number.isInteger(terminal.detail.earliest_offset) || terminal.detail.earliest_offset < 0 ||
|
|
226
317
|
terminal.detail.stream_head < terminal.detail.earliest_offset || terminal.detail.stream_head - terminal.detail.earliest_offset !== output.length) continue;
|
|
318
|
+
const operationHistoryPath=path.join(policy.state_dir,`${sessionId}.operation.jsonl`);
|
|
319
|
+
const operationReceiptPath=path.join(policy.state_dir,`${sessionId}.operation-receipt.json`);
|
|
320
|
+
let operationReceipt, operationRecords, operationId;
|
|
321
|
+
try {
|
|
322
|
+
operationReceipt=JSON.parse(await readFile(operationReceiptPath,'utf8'));
|
|
323
|
+
const {key_id:keyId,signature,...receiptBody}=operationReceipt;
|
|
324
|
+
const receiptCanonical=Buffer.from(canonicalJson(receiptBody),'utf8');
|
|
325
|
+
if(operationReceipt.schema!=='deadbyte.operation-terminal-receipt.v1'||operationReceipt.session_id!==sessionId||
|
|
326
|
+
operationReceipt.policy_sha256!==policy.policy_sha256||keyId!==verifying.keyId||
|
|
327
|
+
!cryptoVerify(null,Buffer.concat([Buffer.from('DEADBYTE-OPERATION-RECEIPT-V1\\0','utf8'),receiptCanonical]),verifying.publicKey,Buffer.from(signature,'base64'))) continue;
|
|
328
|
+
operationId=operationReceipt.operation_id;
|
|
329
|
+
operationRecords=textToOperationRecords(await readFile(operationHistoryPath,'utf8'));
|
|
330
|
+
if(!operationRecords.length||operationRecords.at(-1).event_hash!==operationReceipt.terminal.terminal_event_hash||
|
|
331
|
+
operationRecords.at(-1).sequence!==operationReceipt.terminal.terminal_sequence) continue;
|
|
332
|
+
} catch { continue; }
|
|
333
|
+
|
|
334
|
+
const terminalOperation=operationReceipt.terminal;
|
|
335
|
+
const operationSnapshot={
|
|
336
|
+
schema:'deadbyte.operation-lifecycle.v1',
|
|
337
|
+
operation_id:operationId,
|
|
338
|
+
state:terminalOperation.terminal_type,
|
|
339
|
+
terminal:{type:terminalOperation.terminal_type,sequence:terminalOperation.terminal_sequence,event_hash:terminalOperation.terminal_event_hash},
|
|
340
|
+
history_head_sha256:terminalOperation.head_event_hash,
|
|
341
|
+
next_sequence:terminalOperation.next_sequence,
|
|
342
|
+
overflow:terminalOperation.overflow
|
|
343
|
+
};
|
|
227
344
|
sessions.set(sessionId, {
|
|
228
345
|
session_id:sessionId, job_id:null, profile_id:terminal.profile_id, root_id:terminal.root_id,
|
|
346
|
+
operation_lifecycle:{operationId,snapshot:()=>operationSnapshot},
|
|
347
|
+
operation_history_path:operationHistoryPath, operation_receipt_path:operationReceiptPath,
|
|
348
|
+
operation_history_head_sha256:terminalOperation.head_event_hash,
|
|
349
|
+
operation_sequence:terminalOperation.terminal_sequence,
|
|
350
|
+
operation_receipt:operationReceipt, operation_event_chain:Promise.resolve(), operation_history_error:null,
|
|
229
351
|
cwd_relative:typeof started.detail?.cwd === 'string' ? started.detail.cwd : '.',
|
|
230
352
|
child_pid:started.detail.child_pid, creation_filetime:String(started.detail.creation_filetime),
|
|
231
353
|
state:terminal.type, exit_code:Number.isInteger(terminal.detail.exit_code) ? terminal.detail.exit_code : null,
|
|
@@ -300,6 +422,7 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
300
422
|
session.child_pid = record.child_pid;
|
|
301
423
|
session.creation_filetime = String(record.creation_filetime);
|
|
302
424
|
session.state = 'running';
|
|
425
|
+
queueOperationEvent(session,'running',{child_pid:session.child_pid,creation_filetime:session.creation_filetime}).catch(()=>{});
|
|
303
426
|
session.ready_resolve?.();
|
|
304
427
|
} else if (record.child_pid === session.child_pid && Number.isInteger(record.exit_code)) {
|
|
305
428
|
session.exit_code = record.exit_code;
|
|
@@ -327,10 +450,16 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
327
450
|
const sessionId = randomBytes(32).toString('hex');
|
|
328
451
|
const session = {
|
|
329
452
|
session_id: sessionId, job_id: randomBytes(32).toString('hex'), profile_id: profileId, root_id: rootId,
|
|
453
|
+
operation_lifecycle:createOperationLifecycle({operationId:randomBytes(32).toString('hex')}),
|
|
454
|
+
operation_history_path:path.join(policy.state_dir, `${sessionId}.operation.jsonl`),
|
|
455
|
+
operation_receipt_path:path.join(policy.state_dir, `${sessionId}.operation-receipt.json`),
|
|
456
|
+
operation_history_head_sha256:ZERO_SHA256, operation_sequence:0, operation_receipt:null, operation_event_chain:Promise.resolve(), operation_history_error:null,
|
|
330
457
|
cwd_relative: cwdInfo.relative, child_pid: null, creation_filetime: null, state: 'starting', exit_code: null,
|
|
331
458
|
earliest_offset: 0, stream_head: 0, stream_sha256: ZERO_SHA256, output_chunks: [], retained_output_bytes: 0, control_buffer: Buffer.alloc(0),
|
|
332
459
|
journal_head_sha256: ZERO_SHA256, journal_path: path.join(policy.state_dir, `${sessionId}.jsonl`), event_count: 0,
|
|
333
|
-
max_retained_output_bytes: profile.max_retained_output_bytes, output_stream: createSequenceStream({streamId: sessionId, maxItems: 1000000, maxBytes: profile.max_retained_output_bytes}), stderr: '',
|
|
460
|
+
max_retained_output_bytes: profile.max_retained_output_bytes, output_stream: createSequenceStream({streamId: sessionId, maxItems: 1000000, maxBytes: profile.max_retained_output_bytes}), stderr: '',
|
|
461
|
+
readiness: createReadinessTracker({ maxEvidenceBytes: Math.min(65536, Math.max(4096, profile.max_retained_output_bytes)) }),
|
|
462
|
+
terminate_requested: false, protocol_error: null,
|
|
334
463
|
deadline: createDeadline({ timeoutMs, id: sessionId }),
|
|
335
464
|
cancellation: createCancellationToken({ id: sessionId }),
|
|
336
465
|
termination: classifyTermination(),
|
|
@@ -345,6 +474,10 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
345
474
|
{ cwd: cwdInfo.resolved, env: processEnvironment(profile), windowsHide: true, shell: false, stdio: ['pipe','pipe','pipe'] });
|
|
346
475
|
session.host = host;
|
|
347
476
|
sessions.set(sessionId, session);
|
|
477
|
+
await queueOperationEvent(session,'start',{
|
|
478
|
+
profile_id:profileId,root_id:rootId,cwd:cwdInfo.relative,
|
|
479
|
+
args_sha256:sha256Bytes(Buffer.from(canonicalJson(args),'utf8'))
|
|
480
|
+
});
|
|
348
481
|
const abortProcess = () => {
|
|
349
482
|
const reason = String(composed.signal.reason ?? 'cancelled');
|
|
350
483
|
const snapshot = session.cancellation.snapshot();
|
|
@@ -355,19 +488,39 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
355
488
|
verified: false
|
|
356
489
|
});
|
|
357
490
|
session.terminate_requested = true;
|
|
491
|
+
if (!session.operation_cancel_transition) {
|
|
492
|
+
session.operation_cancel_transition=true;
|
|
493
|
+
if (snapshot.requested) {
|
|
494
|
+
queueOperationEvent(session,'cancel_requested',{reason:snapshot.reason ?? 'operator_request'}).catch(()=>{});
|
|
495
|
+
}
|
|
496
|
+
queueOperationEvent(session,'canceling',{
|
|
497
|
+
reason:reason,
|
|
498
|
+
deadline_exceeded:reason==='deadline_exceeded',
|
|
499
|
+
cancellation_requested:snapshot.requested
|
|
500
|
+
}).catch(()=>{});
|
|
501
|
+
}
|
|
358
502
|
if (!host.killed) host.kill();
|
|
359
503
|
};
|
|
360
504
|
composed.signal.addEventListener('abort', abortProcess, { once: true });
|
|
361
505
|
if (composed.signal.aborted) abortProcess();
|
|
362
506
|
const ready = new Promise((resolve, reject) => { session.ready_resolve = resolve; session.ready_reject = reject; });
|
|
363
|
-
host.stdout.on('data', chunk =>
|
|
364
|
-
|
|
507
|
+
host.stdout.on('data', chunk => {
|
|
508
|
+
session.readiness.append('stdout', chunk);
|
|
509
|
+
parseHostOutput(session, chunk);
|
|
510
|
+
queueOperationEvent(session,'output',{channel:'stdout',bytes:Buffer.byteLength(chunk),payload_digest:sha256Bytes(Buffer.from(chunk))}).catch(()=>{});
|
|
511
|
+
});
|
|
512
|
+
host.stderr.on('data', chunk => {
|
|
513
|
+
session.stderr = (session.stderr + chunk.toString('utf8')).slice(-16384);
|
|
514
|
+
session.readiness.append('stderr', chunk);
|
|
515
|
+
queueOperationEvent(session,'error',{channel:'stderr',bytes:Buffer.byteLength(chunk),payload_digest:sha256Bytes(Buffer.from(chunk))}).catch(()=>{});
|
|
516
|
+
});
|
|
365
517
|
host.once('error', error => session.ready_reject?.(error));
|
|
366
518
|
session.close_promise = new Promise(resolve => {
|
|
367
519
|
host.once('close', async code => {
|
|
368
520
|
parseHostOutput(session, null, true);
|
|
369
521
|
if (session.state === 'starting') session.ready_reject?.(new Error(`process host exited before READY: ${session.stderr}`));
|
|
370
522
|
session.exit_code ??= Number.isInteger(code) ? code : null;
|
|
523
|
+
session.readiness.updateTerminal(session.terminate_requested ? 'terminated' : 'exited', session.exit_code);
|
|
371
524
|
const snapshot = session.cancellation.snapshot();
|
|
372
525
|
session.termination = classifyTermination({
|
|
373
526
|
deadlineExceeded: session.deadline.deadline_ms !== null && Date.now() >= session.deadline.deadline_ms && session.terminate_requested,
|
|
@@ -376,6 +529,8 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
376
529
|
verified: true
|
|
377
530
|
});
|
|
378
531
|
const terminalState = session.terminate_requested ? 'terminated' : 'exited';
|
|
532
|
+
const deadlineExceeded=session.deadline.deadline_ms !== null && Date.now() >= session.deadline.deadline_ms && session.terminate_requested;
|
|
533
|
+
const operationTerminal=deadlineExceeded ? 'timed_out' : (session.terminate_requested || snapshot.requested ? 'cancelled' : (session.exit_code===0 ? 'success' : 'failure'));
|
|
379
534
|
try {
|
|
380
535
|
const outputFile = `${session.session_id}.stream.bin`;
|
|
381
536
|
const retainedOutput = retainedAll(session);
|
|
@@ -393,8 +548,20 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
393
548
|
deadline:session.deadline,
|
|
394
549
|
output_file:outputFile
|
|
395
550
|
});
|
|
551
|
+
await queueOperationEvent(session,operationTerminal,{
|
|
552
|
+
exit_code:session.exit_code,
|
|
553
|
+
process_state:terminalState,
|
|
554
|
+
termination:session.termination,
|
|
555
|
+
stream_head:session.stream_head,
|
|
556
|
+
stream_sha256:session.stream_sha256
|
|
557
|
+
});
|
|
558
|
+
await session.operation_event_chain;
|
|
559
|
+
await createSignedOperationReceipt(session);
|
|
396
560
|
session.state = terminalState;
|
|
397
|
-
} catch {
|
|
561
|
+
} catch(error) {
|
|
562
|
+
session.operation_history_error=String(error?.message ?? error);
|
|
563
|
+
session.state = terminalState;
|
|
564
|
+
}
|
|
398
565
|
composed.close();
|
|
399
566
|
resolve();
|
|
400
567
|
});
|
|
@@ -495,6 +662,11 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
495
662
|
async function terminate({ sessionId }) {
|
|
496
663
|
const session = await sessionFor(sessionId);
|
|
497
664
|
if (['exited','terminated'].includes(session.state)) return publicSession(session);
|
|
665
|
+
if (!session.operation_cancel_transition) {
|
|
666
|
+
session.operation_cancel_transition=true;
|
|
667
|
+
await queueOperationEvent(session,'cancel_requested',{reason:'physical_termination'});
|
|
668
|
+
await queueOperationEvent(session,'canceling',{reason:'physical_termination',cancellation_requested:false});
|
|
669
|
+
}
|
|
498
670
|
session.terminate_requested = true;
|
|
499
671
|
if (!session.host.killed) session.host.kill();
|
|
500
672
|
await session.close_promise;
|
|
@@ -527,5 +699,5 @@ export function createProcessRuntime({ policy, signingKeyPath, verifyKeyPath })
|
|
|
527
699
|
return { ok: true, event_count: sequence, head_sha256: previous };
|
|
528
700
|
}
|
|
529
701
|
|
|
530
|
-
return { policy, start, read, write, status, list, action, cancel, terminate, verifyEventJournal };
|
|
702
|
+
return { policy, start, read, write, status, list, action, cancel, terminate, verifyEventJournal, verifyOperationEvidence };
|
|
531
703
|
}
|