deadbyte-mcp 0.14.0 → 0.15.0
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/AGENTS.md +11 -18
- package/CONTEXT.md +3 -3
- package/MANIFEST.SHA256 +104 -53
- package/R43-PARENT.json +20 -0
- package/R44-DONORS.json +99 -0
- package/R44-PARENT.json +18 -0
- package/README.txt +7 -2
- package/controller/README.TXT +4 -5
- package/controller/deadbyte-controller.ps1 +1 -1
- package/docs/ARCHITECTURE.md +8 -1
- package/docs/R44-ARCHITECTURE.md +37 -0
- package/package.json +13 -8
- package/scripts/deferred-slot-operation-r43.mjs +126 -0
- package/scripts/deferred-slot-operation-r44.mjs +126 -0
- package/scripts/final-closure-r43.mjs +115 -0
- package/scripts/final-closure-r44.mjs +121 -0
- package/scripts/final-closure-verify-r43.mjs +213 -0
- package/scripts/final-closure-verify-r44.mjs +220 -0
- package/scripts/finalize-r43.mjs +217 -0
- package/scripts/finalize-r44.mjs +217 -0
- package/scripts/full-regression-r43.mjs +20 -0
- package/scripts/full-regression-r44.mjs +20 -0
- package/scripts/gate-windows-r43.ps1 +36 -0
- package/scripts/gate-windows-r44.ps1 +36 -0
- package/scripts/release-parity-r43.mjs +218 -0
- package/scripts/release-parity-r44.mjs +242 -0
- package/scripts/release-parity-tests-r43.ps1 +8 -0
- package/scripts/release-parity-tests-r44.ps1 +8 -0
- package/scripts/verify-r43-parent.mjs +126 -0
- package/scripts/verify-r44-parent.mjs +142 -0
- package/scripts/windows-gate-evidence-r43.mjs +55 -0
- package/scripts/windows-gate-evidence-r44.mjs +55 -0
- package/src/agent-backend-r44.mjs +254 -0
- package/src/autonomous-output-contracts.mjs +1 -1
- package/src/autonomous-policy.mjs +41 -4
- package/src/autonomous-runtime.mjs +25 -12
- package/src/autonomous-supervisor-host.mjs +12 -2
- package/src/capability-ledger.mjs +2 -1
- package/src/codex-app-server-r44.mjs +154 -0
- package/src/coding-plane.mjs +66 -19
- package/src/durable-job-r44.mjs +145 -0
- package/src/external-job-provider-r44.mjs +126 -0
- package/src/host-plane.mjs +27 -6
- package/src/job-supervisor-host-r44.mjs +112 -0
- package/src/machine-fs-smoke-client.mjs +1 -1
- package/src/machine-fs.mjs +21 -10
- package/src/mcp-host-smoke-client.mjs +1 -1
- package/src/mcp-server.mjs +20 -3
- package/src/mcp-smoke-client.mjs +1 -1
- package/src/native-diff-v2.mjs +3 -3
- package/src/native-diff.mjs +3 -2
- package/src/observation-smoke-client.mjs +1 -1
- package/src/operator-surface.mjs +2 -2
- package/src/process-policy.mjs +19 -0
- package/src/process-runtime.mjs +5 -2
- package/src/process-smoke-client.mjs +2 -2
- package/src/provider-circuit.mjs +8 -2
- package/src/r44-compact-disarmed-probe.mjs +74 -0
- package/src/r44-full-surface-probe.mjs +34 -0
- package/src/remote-device-r44.mjs +205 -0
- package/src/remote-mcp-autonomous-smoke-client.mjs +4 -4
- package/src/remote-mcp-r44-parity-client.mjs +203 -0
- package/src/remote-mcp-smoke-client.mjs +7 -4
- package/src/runtime-update.mjs +64 -31
- package/src/skill-mcp-tools-r44.mjs +37 -0
- package/src/skill-plane-r44.mjs +203 -0
- package/src/tunnel-stdio-compat-smoke.mjs +1 -1
- package/src/version.mjs +1 -1
- package/test/agent-backend-r44.test.mjs +115 -0
- package/test/agent-runtime.test.mjs +3 -3
- package/test/autonomous-loop.test.mjs +97 -11
- package/test/autonomous-runtime.test.mjs +2 -2
- package/test/capability-ledger-r36.test.mjs +3 -3
- package/test/cli-entrypoint.test.mjs +1 -1
- package/test/codex-app-server-r44.test.mjs +115 -0
- package/test/codex-autonomous-r44.test.mjs +87 -0
- package/test/coding-plane.test.mjs +2 -2
- package/test/coding-runtime.test.mjs +36 -1
- package/test/compact-catalog-r36.test.mjs +3 -3
- package/test/compact-max-catalog-r40.test.mjs +4 -2
- package/test/compact-max-catalog-r44.test.mjs +97 -0
- package/test/core.test.mjs +1 -1
- package/test/durable-job-r44.test.mjs +75 -0
- package/test/external-job-provider-r44.test.mjs +46 -0
- package/test/full-max-catalog-r40.test.mjs +5 -3
- package/test/full-max-catalog-r44.test.mjs +91 -0
- package/test/host-plane.test.mjs +15 -1
- package/test/native-diff.test.mjs +28 -0
- package/test/output-schema.test.mjs +2 -2
- package/test/process-policy.test.mjs +22 -1
- package/test/prompts-resources.test.mjs +1 -1
- package/test/r33-closeout-regression.test.mjs +13 -30
- package/test/r33-finalization.test.mjs +7 -7
- package/test/r43-finalization.test.mjs +31 -0
- package/test/r43-finalizer.test.mjs +34 -0
- package/test/r43-parent.test.mjs +29 -0
- package/test/r44-finalization.test.mjs +58 -0
- package/test/r44-finalizer.test.mjs +48 -0
- package/test/r44-parent.test.mjs +25 -0
- package/test/release-version.test.mjs +53 -19
- package/test/remote-device-r44.test.mjs +104 -0
- package/test/runtime-self-update.test.mjs +42 -0
- package/test/skill-mcp-r44.test.mjs +38 -0
- package/test/skill-plane-r44.test.mjs +93 -0
- package/test/standing-grants.test.mjs +5 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createAgentBackendR44 } from './agent-backend-r44.mjs';
|
|
3
|
+
|
|
4
|
+
const ID=/^[A-Za-z0-9._-]{1,64}$/;
|
|
5
|
+
function insist(ok,message){if(!ok)throw new Error(message);}
|
|
6
|
+
function text(value,label,max=262144){insist(typeof value==='string'&&value.length>=1&&value.length<=max,`${label} invalid`);return value;}
|
|
7
|
+
function threadIdFromResponse(value){
|
|
8
|
+
const id=value?.thread?.id;insist(typeof id==='string'&&id.length>=1&&id.length<=512,'Codex thread response missing thread.id');return id;
|
|
9
|
+
}
|
|
10
|
+
function turnIdFromResponse(value){
|
|
11
|
+
const id=value?.turn?.id;insist(typeof id==='string'&&id.length>=1&&id.length<=512,'Codex turn response missing turn.id');return id;
|
|
12
|
+
}
|
|
13
|
+
function safeThreadParams({model,cwd}){
|
|
14
|
+
return {model,cwd,approvalPolicy:'never',approvalsReviewer:'user',sandbox:'read-only',ephemeral:false};
|
|
15
|
+
}
|
|
16
|
+
function approvalHandler({method}){
|
|
17
|
+
if(method==='item/commandExecution/requestApproval'||method==='item/fileChange/requestApproval')return {decision:'decline'};
|
|
18
|
+
if(method==='item/permissions/requestApproval')return {permissions:{},scope:'turn'};
|
|
19
|
+
if(method==='item/tool/requestUserInput')return {answers:{}};
|
|
20
|
+
const error=new Error(`Codex server request denied: ${method}`);error.code=-32001;throw error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function createCodexAppServerR44({
|
|
24
|
+
stateDir,model,cwd,clientVersion='0.15.0',transportFactory=null,
|
|
25
|
+
executable=null,executableSha256=null,args=['app-server'],env=null
|
|
26
|
+
}={}){
|
|
27
|
+
insist(typeof stateDir==='string'&&path.isAbsolute(stateDir),'Codex stateDir must be absolute');
|
|
28
|
+
text(model,'Codex model',128);insist(typeof cwd==='string'&&path.isAbsolute(cwd),'Codex cwd must be absolute');
|
|
29
|
+
text(clientVersion,'Codex client version',64);
|
|
30
|
+
const turns=new Map();
|
|
31
|
+
const attachedThreads=new Set();
|
|
32
|
+
let initialized=false;
|
|
33
|
+
|
|
34
|
+
function turnRecord(turnId,threadId=null){
|
|
35
|
+
let rec=turns.get(turnId);
|
|
36
|
+
if(!rec){rec={turn_id:turnId,thread_id:threadId,final_answer:null,unknown_answer:null,status:null,error:null,waiters:[]};turns.set(turnId,rec);}
|
|
37
|
+
else if(threadId&&rec.thread_id===null)rec.thread_id=threadId;
|
|
38
|
+
return rec;
|
|
39
|
+
}
|
|
40
|
+
function settle(rec){
|
|
41
|
+
if(!rec.status||!['completed','failed','interrupted'].includes(rec.status))return;
|
|
42
|
+
const waiters=rec.waiters.splice(0);
|
|
43
|
+
if(rec.status==='completed'){
|
|
44
|
+
const raw=rec.final_answer??rec.unknown_answer;
|
|
45
|
+
for(const waiter of waiters){
|
|
46
|
+
clearTimeout(waiter.timer);
|
|
47
|
+
if(typeof raw==='string'&&raw.length>0)waiter.resolve({raw_text:raw});
|
|
48
|
+
else waiter.reject(new Error('Codex turn completed without agent final response'));
|
|
49
|
+
}
|
|
50
|
+
}else{
|
|
51
|
+
for(const waiter of waiters){clearTimeout(waiter.timer);waiter.reject(new Error(rec.error??`Codex turn ${rec.status}`));}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function notificationHandler({method,params}){
|
|
55
|
+
if(method==='item/completed'){
|
|
56
|
+
const tid=params?.turnId,threadId=params?.threadId,item=params?.item;
|
|
57
|
+
if(typeof tid!=='string'||!item||typeof item!=='object')return;
|
|
58
|
+
const rec=turnRecord(tid,typeof threadId==='string'?threadId:null);
|
|
59
|
+
if(item.type==='agentMessage'||item.type==='agent_message'){
|
|
60
|
+
if(typeof item.text!=='string')return;
|
|
61
|
+
if(item.phase==='final_answer'||item.phase==='finalAnswer')rec.final_answer=item.text;
|
|
62
|
+
else if(item.phase===null||item.phase===undefined)rec.unknown_answer=item.text;
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if(method==='turn/completed'){
|
|
67
|
+
const turn=params?.turn;const tid=turn?.id;
|
|
68
|
+
if(typeof tid!=='string')return;
|
|
69
|
+
const rec=turnRecord(tid,typeof params?.threadId==='string'?params.threadId:null);
|
|
70
|
+
rec.status=String(turn.status??'completed');
|
|
71
|
+
if(rec.status==='failed')rec.error=turn.error?.message??'Codex turn failed';
|
|
72
|
+
settle(rec);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const backend=await createAgentBackendR44({
|
|
77
|
+
backendId:'codex-app-server',stateDir:path.join(stateDir,'backend'),transportFactory,
|
|
78
|
+
executable,executableSha256,args,cwd,env,wireMode:'codex-app-server',
|
|
79
|
+
serverRequestHandler:approvalHandler,notificationHandler
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function start(){
|
|
83
|
+
if(initialized)return {status:'ok',initialized:true};
|
|
84
|
+
await backend.start();
|
|
85
|
+
await backend.request({method:'initialize',params:{
|
|
86
|
+
clientInfo:{name:'deadbyte-mcp',version:clientVersion},
|
|
87
|
+
capabilities:{experimentalApi:false,requestAttestation:false,optOutNotificationMethods:null}
|
|
88
|
+
},timeoutMs:30000});
|
|
89
|
+
await backend.notify({method:'initialized',params:{}});
|
|
90
|
+
initialized=true;
|
|
91
|
+
return {status:'ok',initialized:true,backend:'codex_app_server'};
|
|
92
|
+
}
|
|
93
|
+
async function ensureStarted(){if(!initialized)await start();}
|
|
94
|
+
async function ensureThread({logicalId}={}){
|
|
95
|
+
await ensureStarted();insist(typeof logicalId==='string'&&ID.test(logicalId),'Codex logical thread id invalid');
|
|
96
|
+
const status=await backend.status();const existing=status.threads[logicalId];
|
|
97
|
+
if(existing){
|
|
98
|
+
if(!attachedThreads.has(existing)){
|
|
99
|
+
const response=await backend.request({method:'thread/resume',params:{threadId:existing,...safeThreadParams({model,cwd}),excludeTurns:true},timeoutMs:60000});
|
|
100
|
+
const resumed=threadIdFromResponse(response);insist(resumed===existing,'Codex resumed thread identity mismatch');attachedThreads.add(existing);
|
|
101
|
+
}
|
|
102
|
+
return {status:'ok',logical_id:logicalId,thread_id:existing,resumed:true};
|
|
103
|
+
}
|
|
104
|
+
const response=await backend.request({method:'thread/start',params:safeThreadParams({model,cwd}),timeoutMs:60000});
|
|
105
|
+
const created=threadIdFromResponse(response);
|
|
106
|
+
await backend.bindThread({logicalId,threadId:created});attachedThreads.add(created);
|
|
107
|
+
return {status:'ok',logical_id:logicalId,thread_id:created,resumed:false};
|
|
108
|
+
}
|
|
109
|
+
function waitTurn(turnId,threadId,timeoutMs){
|
|
110
|
+
insist(Number.isInteger(timeoutMs)&&timeoutMs>=100&&timeoutMs<=900000,'Codex turn timeout invalid');
|
|
111
|
+
const rec=turnRecord(turnId,threadId);
|
|
112
|
+
if(rec.status&&['completed','failed','interrupted'].includes(rec.status)){
|
|
113
|
+
return new Promise((resolve,reject)=>{
|
|
114
|
+
const timer=setTimeout(()=>{},1);rec.waiters.push({resolve,reject,timer});settle(rec);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return new Promise((resolve,reject)=>{
|
|
118
|
+
const timer=setTimeout(()=>{
|
|
119
|
+
const index=rec.waiters.findIndex(x=>x.timer===timer);if(index>=0)rec.waiters.splice(index,1);
|
|
120
|
+
reject(new Error('Codex turn completion timeout'));
|
|
121
|
+
},timeoutMs);
|
|
122
|
+
rec.waiters.push({resolve,reject,timer});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async function runPlanner({logicalId='planner',prompt,timeoutMs=120000}={}){
|
|
126
|
+
text(prompt,'Codex planner prompt',1024*1024);
|
|
127
|
+
const thread=await ensureThread({logicalId});
|
|
128
|
+
const started=await backend.request({method:'turn/start',params:{
|
|
129
|
+
threadId:thread.thread_id,input:[{type:'text',text:prompt}],approvalPolicy:'never'
|
|
130
|
+
},timeoutMs:Math.min(timeoutMs,60000)});
|
|
131
|
+
const turnId=turnIdFromResponse(started);turnRecord(turnId,thread.thread_id);
|
|
132
|
+
const completed=await waitTurn(turnId,thread.thread_id,timeoutMs);
|
|
133
|
+
return {backend:'codex_app_server',model,raw_text:completed.raw_text,response_id:turnId,thread_id:thread.thread_id};
|
|
134
|
+
}
|
|
135
|
+
async function steer({threadId,turnId,text:message,timeoutMs=30000}={}){
|
|
136
|
+
await ensureStarted();text(threadId,'Codex threadId',512);text(turnId,'Codex turnId',512);text(message,'Codex steer text',262144);
|
|
137
|
+
return backend.request({method:'turn/steer',params:{threadId,expectedTurnId:turnId,input:[{type:'text',text:message}]},timeoutMs});
|
|
138
|
+
}
|
|
139
|
+
async function interrupt({threadId,turnId,timeoutMs=30000}={}){
|
|
140
|
+
await ensureStarted();text(threadId,'Codex threadId',512);text(turnId,'Codex turnId',512);
|
|
141
|
+
return backend.request({method:'turn/interrupt',params:{threadId,turnId},timeoutMs});
|
|
142
|
+
}
|
|
143
|
+
async function modelList({includeHidden=false}={}){
|
|
144
|
+
await ensureStarted();return backend.request({method:'model/list',params:{includeHidden:includeHidden===true},timeoutMs:30000});
|
|
145
|
+
}
|
|
146
|
+
async function status(){const value=await backend.status();return {...value,initialized,attached_threads:[...attachedThreads].sort()};}
|
|
147
|
+
async function close(){
|
|
148
|
+
for(const rec of turns.values()){
|
|
149
|
+
for(const waiter of rec.waiters.splice(0)){clearTimeout(waiter.timer);waiter.reject(new Error('Codex adapter closed before turn completion'));}
|
|
150
|
+
}
|
|
151
|
+
initialized=false;attachedThreads.clear();return backend.close();
|
|
152
|
+
}
|
|
153
|
+
return Object.freeze({start,ensureThread,runPlanner,steer,interrupt,modelList,status,close,backend});
|
|
154
|
+
}
|
package/src/coding-plane.mjs
CHANGED
|
@@ -153,6 +153,25 @@ export async function assertCodingArmed(policy, rootId = null, now = new Date())
|
|
|
153
153
|
return status;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
export async function recheckCodingAuthority(policy, initial, rootId = null, now = new Date()) {
|
|
157
|
+
if (!initial?.armed || typeof initial.instance_nonce !== 'string' || !Array.isArray(initial.root_ids)) {
|
|
158
|
+
throw new Error('coding authority recheck requires an armed initial snapshot');
|
|
159
|
+
}
|
|
160
|
+
let current;
|
|
161
|
+
try { current = await assertCodingArmed(policy, rootId, now); }
|
|
162
|
+
catch (error) { throw new Error(`coding authority changed during operation: ${String(error?.message ?? error)}`); }
|
|
163
|
+
const initialRoots = [...initial.root_ids].sort();
|
|
164
|
+
const currentRoots = [...current.root_ids].sort();
|
|
165
|
+
const changed =
|
|
166
|
+
current.instance_nonce !== initial.instance_nonce ||
|
|
167
|
+
current.standing !== initial.standing ||
|
|
168
|
+
current.expires_at_utc !== initial.expires_at_utc ||
|
|
169
|
+
initialRoots.length !== currentRoots.length ||
|
|
170
|
+
initialRoots.some((id, index) => id !== currentRoots[index]);
|
|
171
|
+
if (changed) throw new Error('coding authority changed during operation');
|
|
172
|
+
return current;
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
function getRoot(policy, rootId, permission) {
|
|
157
176
|
if (!ID_RE.test(rootId)) throw new Error('invalid coding root id');
|
|
158
177
|
const root = policy.roots[rootId];
|
|
@@ -185,7 +204,7 @@ async function nearestExistingParent(candidate) {
|
|
|
185
204
|
}
|
|
186
205
|
}
|
|
187
206
|
}
|
|
188
|
-
async function resolveCodingWritable(policy, lease, rootId, relativePath) {
|
|
207
|
+
async function resolveCodingWritable(policy, lease, rootId, relativePath, authorityCheck = null) {
|
|
189
208
|
validatePortableRelativePath(relativePath);
|
|
190
209
|
if (!lease.root_ids.includes(rootId)) throw new Error(`coding root '${rootId}' is outside active lease`);
|
|
191
210
|
const root = getRoot(policy, rootId, 'write');
|
|
@@ -196,6 +215,7 @@ async function resolveCodingWritable(policy, lease, rootId, relativePath) {
|
|
|
196
215
|
const existingParent = await nearestExistingParent(parent);
|
|
197
216
|
const realExisting = await realpath(existingParent);
|
|
198
217
|
if (!isInside(root.real_path, realExisting)) throw new Error('coding parent symlink escapes allowed root');
|
|
218
|
+
if (authorityCheck) await authorityCheck();
|
|
199
219
|
await mkdir(parent, { recursive: true });
|
|
200
220
|
const realParent = await realpath(parent);
|
|
201
221
|
if (!isInside(root.real_path, realParent)) throw new Error('coding parent resolves outside allowed root');
|
|
@@ -240,12 +260,13 @@ function decodeContent(content, encoding) {
|
|
|
240
260
|
}
|
|
241
261
|
throw new Error('encoding must be utf8 or base64');
|
|
242
262
|
}
|
|
243
|
-
async function atomicWrite(target, bytes, expectedSha256) {
|
|
263
|
+
async function atomicWrite(target, bytes, expectedSha256, authorityCheck = null) {
|
|
244
264
|
const before = await targetState(target);
|
|
245
265
|
checkExpected(before, expectedSha256);
|
|
246
266
|
const temp = path.join(path.dirname(target), `.${path.basename(target)}.deadbyte-coding-${process.pid}-${randomBytes(8).toString('hex')}.tmp`);
|
|
247
267
|
let handle;
|
|
248
268
|
try {
|
|
269
|
+
if (authorityCheck) await authorityCheck();
|
|
249
270
|
handle = await open(temp, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 0o600);
|
|
250
271
|
await handle.writeFile(bytes);
|
|
251
272
|
await handle.sync();
|
|
@@ -253,6 +274,7 @@ async function atomicWrite(target, bytes, expectedSha256) {
|
|
|
253
274
|
handle = null;
|
|
254
275
|
const race = await targetState(target);
|
|
255
276
|
checkExpected(race, expectedSha256);
|
|
277
|
+
if (authorityCheck) await authorityCheck();
|
|
256
278
|
await rename(temp, target);
|
|
257
279
|
} catch (error) {
|
|
258
280
|
if (handle) await handle.close().catch(() => {});
|
|
@@ -394,13 +416,15 @@ export function createCodingRuntime({ policy, signingKeyPath, verifyKeyPath }) {
|
|
|
394
416
|
}
|
|
395
417
|
async function fileWrite({ rootId, relativePath, content, encoding = 'utf8', expectedSha256, receiptId = null, requestSha256 = null }) {
|
|
396
418
|
const lease = await assertCodingArmed(policy, rootId);
|
|
397
|
-
|
|
419
|
+
let activeLease = lease;
|
|
420
|
+
const authorityCheck = async () => { activeLease = await recheckCodingAuthority(policy, lease, rootId); return activeLease; };
|
|
421
|
+
const { lexical } = await resolveCodingWritable(policy, lease, rootId, relativePath, authorityCheck);
|
|
398
422
|
const bytes = decodeContent(content, encoding);
|
|
399
423
|
if (bytes.length > policy.max_write_bytes) throw new Error(`coding write exceeds ${policy.max_write_bytes} byte limit`);
|
|
400
424
|
const request = requestSha256 ?? requestSha('coding_file_write', { root_id: rootId, path: relativePath, encoding, expected_sha256: expectedSha256, content_sha256: sha256Bytes(bytes) });
|
|
401
425
|
const started = new Date().toISOString();
|
|
402
|
-
const { before, after } = await atomicWrite(lexical, bytes, expectedSha256);
|
|
403
|
-
const signed = await signCodingReceipt(policy, signingKeyPath,
|
|
426
|
+
const { before, after } = await atomicWrite(lexical, bytes, expectedSha256, authorityCheck);
|
|
427
|
+
const signed = await signCodingReceipt(policy, signingKeyPath, activeLease, {
|
|
404
428
|
operation: 'coding_file_write', request_sha256: request, root_id: rootId, path: relativePath,
|
|
405
429
|
started_at_utc: started, completed_at_utc: new Date().toISOString(),
|
|
406
430
|
before_sha256: before.sha256, after_sha256: after.sha256, bytes: after.bytes
|
|
@@ -421,11 +445,12 @@ export function createCodingRuntime({ policy, signingKeyPath, verifyKeyPath }) {
|
|
|
421
445
|
const tombstone = path.join(path.dirname(lexical), `.${path.basename(lexical)}.deadbyte-delete-${id}.tmp`);
|
|
422
446
|
const request = requestSha256 ?? requestSha('coding_file_delete', { root_id: rootId, path: relativePath, expected_sha256: expectedSha256 });
|
|
423
447
|
const started = new Date().toISOString();
|
|
448
|
+
const activeLease = await recheckCodingAuthority(policy, lease, rootId);
|
|
424
449
|
await rename(lexical, tombstone);
|
|
425
450
|
try {
|
|
426
451
|
const tombstoneState = await targetState(tombstone);
|
|
427
452
|
if (!tombstoneState.exists || tombstoneState.sha256 !== expectedSha256) throw new Error('coding delete tombstone hash mismatch');
|
|
428
|
-
const signed = await signCodingReceipt(policy, signingKeyPath,
|
|
453
|
+
const signed = await signCodingReceipt(policy, signingKeyPath, activeLease, {
|
|
429
454
|
operation: 'coding_file_delete', request_sha256: request, root_id: rootId, path: relativePath,
|
|
430
455
|
started_at_utc: started, completed_at_utc: new Date().toISOString(),
|
|
431
456
|
before_sha256: expectedSha256, after_sha256: null, bytes: before.bytes
|
|
@@ -463,8 +488,10 @@ export function createCodingRuntime({ policy, signingKeyPath, verifyKeyPath }) {
|
|
|
463
488
|
old_sha256: sha256Bytes(Buffer.from(oldString, 'utf8')), new_sha256: sha256Bytes(Buffer.from(newString, 'utf8'))
|
|
464
489
|
});
|
|
465
490
|
const started = new Date().toISOString();
|
|
466
|
-
|
|
467
|
-
const
|
|
491
|
+
let activeLease = lease;
|
|
492
|
+
const authorityCheck = async () => { activeLease = await recheckCodingAuthority(policy, lease, rootId); return activeLease; };
|
|
493
|
+
const { after } = await atomicWrite(lexical, nextBytes, expectedSha256, authorityCheck);
|
|
494
|
+
const signed = await signCodingReceipt(policy, signingKeyPath, activeLease, {
|
|
468
495
|
operation: 'coding_text_replace', request_sha256: request, root_id: rootId, path: relativePath,
|
|
469
496
|
started_at_utc: started, completed_at_utc: new Date().toISOString(), before_sha256: beforeSha,
|
|
470
497
|
after_sha256: after.sha256, replacements: count, bytes: after.bytes
|
|
@@ -473,7 +500,8 @@ export function createCodingRuntime({ policy, signingKeyPath, verifyKeyPath }) {
|
|
|
473
500
|
bytes: after.bytes, replacements: count, receipt_id: signed.receipt.receipt_id,
|
|
474
501
|
receipt_sha256: signed.receipt.receipt_sha256, request_sha256: request };
|
|
475
502
|
}
|
|
476
|
-
async function runCommandOnce({ rootId, commandId, scriptPath, scriptSha256, args = [], cwd = '.', timeoutMs = null
|
|
503
|
+
async function runCommandOnce({ rootId, commandId, scriptPath, scriptSha256, args = [], cwd = '.', timeoutMs = null,
|
|
504
|
+
executionDeadlineUtc = null, preExecutionCheck = null }) {
|
|
477
505
|
const lease = await assertCodingArmed(policy, rootId);
|
|
478
506
|
if (!ID_RE.test(commandId ?? '')) throw new Error('invalid coding command id');
|
|
479
507
|
const command = policy.commands[commandId];
|
|
@@ -498,8 +526,22 @@ export function createCodingRuntime({ policy, signingKeyPath, verifyKeyPath }) {
|
|
|
498
526
|
if (!(await stat(cwdResolved)).isDirectory()) throw new Error('coding command cwd must be a directory');
|
|
499
527
|
}
|
|
500
528
|
const requestedTimeout = timeoutMs === null ? command.timeout_ms : finiteInteger(timeoutMs, 'timeout_ms', 100, command.timeout_ms, command.timeout_ms);
|
|
501
|
-
|
|
502
|
-
|
|
529
|
+
if (preExecutionCheck !== null) {
|
|
530
|
+
if (typeof preExecutionCheck !== 'function') throw new Error('pre_execution_check must be a function or null');
|
|
531
|
+
await preExecutionCheck();
|
|
532
|
+
}
|
|
533
|
+
const activeLease = await recheckCodingAuthority(policy, lease, rootId);
|
|
534
|
+
const grantBound = Number.isFinite(activeLease.remaining_ms) ? Math.floor(activeLease.remaining_ms) : command.timeout_ms;
|
|
535
|
+
if (grantBound < 100) throw new Error('coding lease expires too soon to execute command');
|
|
536
|
+
let deadlineAtMs = null;
|
|
537
|
+
if (executionDeadlineUtc !== null) {
|
|
538
|
+
if (typeof executionDeadlineUtc !== 'string') throw new Error('execution_deadline_utc must be an ISO timestamp or null');
|
|
539
|
+
deadlineAtMs = Date.parse(executionDeadlineUtc);
|
|
540
|
+
if (!Number.isFinite(deadlineAtMs)) throw new Error('execution_deadline_utc must be a valid ISO timestamp');
|
|
541
|
+
}
|
|
542
|
+
const deadlineBound = deadlineAtMs === null ? command.timeout_ms : Math.floor(deadlineAtMs - Date.now());
|
|
543
|
+
if (deadlineBound < 100) throw new Error('coding execution deadline expires too soon to execute command');
|
|
544
|
+
const effectiveTimeout = Math.min(requestedTimeout, command.timeout_ms, grantBound, deadlineBound);
|
|
503
545
|
const argv = [...command.prefix_args, script.resolved, ...args];
|
|
504
546
|
const startMs = performance.now();
|
|
505
547
|
const child = spawn(command.executable, argv, {
|
|
@@ -512,10 +554,13 @@ export function createCodingRuntime({ policy, signingKeyPath, verifyKeyPath }) {
|
|
|
512
554
|
collectBounded(child.stderr, command.max_output_bytes, stderrState, 'stderr', () => child.kill('SIGKILL'));
|
|
513
555
|
let timedOut = false;
|
|
514
556
|
let timer;
|
|
557
|
+
const timerDelay = deadlineAtMs === null
|
|
558
|
+
? effectiveTimeout
|
|
559
|
+
: Math.max(1, Math.min(effectiveTimeout, Math.floor(deadlineAtMs - Date.now())));
|
|
515
560
|
const completion = new Promise((resolve, reject) => {
|
|
516
561
|
child.once('error', reject);
|
|
517
562
|
child.once('close', (code, signal) => resolve({ code, signal }));
|
|
518
|
-
timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); },
|
|
563
|
+
timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timerDelay);
|
|
519
564
|
timer.unref?.();
|
|
520
565
|
});
|
|
521
566
|
let result;
|
|
@@ -524,22 +569,23 @@ export function createCodingRuntime({ policy, signingKeyPath, verifyKeyPath }) {
|
|
|
524
569
|
const stdoutBytes = Buffer.concat(stdoutState.chunks);
|
|
525
570
|
const stderrBytes = Buffer.concat(stderrState.chunks);
|
|
526
571
|
return {
|
|
527
|
-
lease, command_id: commandId, root_id: rootId,
|
|
572
|
+
lease: activeLease, command_id: commandId, root_id: rootId,
|
|
528
573
|
executable_sha256: actualExecutableHash, script_sha256: actualScriptHash,
|
|
529
574
|
exit_code: result.code, signal: result.signal, timed_out: timedOut,
|
|
530
|
-
timeout_ms:
|
|
575
|
+
timeout_ms: timerDelay, elapsed_ms: Math.round((performance.now() - startMs) * 1000) / 1000,
|
|
531
576
|
stdout: decodeUtf8Strict(stdoutBytes), stderr: decodeUtf8Strict(stderrBytes),
|
|
532
577
|
stdout_sha256: sha256Bytes(stdoutBytes), stderr_sha256: sha256Bytes(stderrBytes)
|
|
533
578
|
};
|
|
534
579
|
}
|
|
535
580
|
|
|
536
|
-
async function commandRun({ rootId, commandId, scriptPath, scriptSha256, args = [], cwd = '.', timeoutMs = null,
|
|
581
|
+
async function commandRun({ rootId, commandId, scriptPath, scriptSha256, args = [], cwd = '.', timeoutMs = null,
|
|
582
|
+
executionDeadlineUtc = null, preExecutionCheck = null, receiptId = null, requestSha256 = null }) {
|
|
537
583
|
const request = requestSha256 ?? requestSha('coding_command_run', {
|
|
538
584
|
root_id: rootId, command_id: commandId, script_path: scriptPath, script_sha256: scriptSha256,
|
|
539
585
|
args, cwd, timeout_ms: timeoutMs
|
|
540
586
|
});
|
|
541
587
|
const started = new Date().toISOString();
|
|
542
|
-
const result = await runCommandOnce({ rootId, commandId, scriptPath, scriptSha256, args, cwd, timeoutMs });
|
|
588
|
+
const result = await runCommandOnce({ rootId, commandId, scriptPath, scriptSha256, args, cwd, timeoutMs, executionDeadlineUtc, preExecutionCheck });
|
|
543
589
|
const signed = await signCodingReceipt(policy, signingKeyPath, result.lease, {
|
|
544
590
|
operation: 'coding_command_run', request_sha256: request, root_id: rootId, command_id: commandId,
|
|
545
591
|
script_path: scriptPath, executable_sha256: result.executable_sha256, script_sha256: result.script_sha256,
|
|
@@ -570,7 +616,8 @@ export function createCodingRuntime({ policy, signingKeyPath, verifyKeyPath }) {
|
|
|
570
616
|
}
|
|
571
617
|
|
|
572
618
|
async function benchmarkRun({ rootId, commandId, scriptPath, scriptSha256, args = [], cwd = '.', timeoutMs = null,
|
|
573
|
-
|
|
619
|
+
executionDeadlineUtc = null, preExecutionCheck = null, warmups = 1, iterations = 5, baselineMedianMs = null, maxRegressionPct = 5,
|
|
620
|
+
receiptId = null, requestSha256 = null }) {
|
|
574
621
|
if (!Number.isInteger(warmups) || warmups < 0 || warmups > 3) throw new Error('warmups must be in [0, 3]');
|
|
575
622
|
if (!Number.isInteger(iterations) || iterations < 1 || iterations > 20) throw new Error('iterations must be in [1, 20]');
|
|
576
623
|
if (baselineMedianMs !== null && (!Number.isFinite(baselineMedianMs) || baselineMedianMs <= 0)) throw new Error('baseline_median_ms must be positive or null');
|
|
@@ -581,13 +628,13 @@ export function createCodingRuntime({ policy, signingKeyPath, verifyKeyPath }) {
|
|
|
581
628
|
max_regression_pct: maxRegressionPct
|
|
582
629
|
});
|
|
583
630
|
for (let i = 0; i < warmups; i += 1) {
|
|
584
|
-
const warm = await runCommandOnce({ rootId, commandId, scriptPath, scriptSha256, args, cwd, timeoutMs });
|
|
631
|
+
const warm = await runCommandOnce({ rootId, commandId, scriptPath, scriptSha256, args, cwd, timeoutMs, executionDeadlineUtc, preExecutionCheck });
|
|
585
632
|
if (warm.timed_out || warm.exit_code !== 0) throw new Error(`benchmark warmup failed exit=${String(warm.exit_code)} timed_out=${warm.timed_out}`);
|
|
586
633
|
}
|
|
587
634
|
const samples = [];
|
|
588
635
|
let last = null;
|
|
589
636
|
for (let i = 0; i < iterations; i += 1) {
|
|
590
|
-
last = await runCommandOnce({ rootId, commandId, scriptPath, scriptSha256, args, cwd, timeoutMs });
|
|
637
|
+
last = await runCommandOnce({ rootId, commandId, scriptPath, scriptSha256, args, cwd, timeoutMs, executionDeadlineUtc, preExecutionCheck });
|
|
591
638
|
if (last.timed_out || last.exit_code !== 0) throw new Error(`benchmark iteration ${i + 1} failed exit=${String(last.exit_code)} timed_out=${last.timed_out}`);
|
|
592
639
|
samples.push(last.elapsed_ms);
|
|
593
640
|
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, open, readFile, realpath, rename } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const SCHEMA='deadbyte.durable-job-state.v1';
|
|
6
|
+
const HEX64=/^[0-9a-f]{64}$/;
|
|
7
|
+
const ID=/^[A-Za-z0-9._-]{1,64}$/;
|
|
8
|
+
const LIFETIMES=new Set(['controller','session','persistent']);
|
|
9
|
+
function insist(ok,message){if(!ok)throw new Error(message);}
|
|
10
|
+
function now(){return new Date().toISOString();}
|
|
11
|
+
async function atomicJson(file,value){
|
|
12
|
+
await mkdir(path.dirname(file),{recursive:true});
|
|
13
|
+
const bytes=Buffer.from(`${JSON.stringify(value,null,2)}\n`,'utf8');
|
|
14
|
+
const temp=`${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
15
|
+
const h=await open(temp,'wx');
|
|
16
|
+
try{await h.writeFile(bytes);await h.sync();}finally{await h.close();}
|
|
17
|
+
await rename(temp,file);
|
|
18
|
+
}
|
|
19
|
+
function validateJob(job,id){
|
|
20
|
+
insist(job&&typeof job==='object'&&!Array.isArray(job)&&job.job_id===id&&HEX64.test(id),'durable job identity invalid');
|
|
21
|
+
insist(LIFETIMES.has(job.lifetime),'durable job lifetime invalid');
|
|
22
|
+
insist(ID.test(job.profile_id??'')&&ID.test(job.root_id??''),'durable job profile/root invalid');
|
|
23
|
+
insist(['starting','running','terminated','failed','interrupted','orphaned'].includes(job.state),'durable job state invalid');
|
|
24
|
+
insist(typeof job.owner_instance_id==='string'&&job.owner_instance_id.length>=1,'durable job owner invalid');
|
|
25
|
+
if(job.state==='running'||job.state==='terminated'||job.state==='orphaned'){
|
|
26
|
+
insist(typeof job.handle_id==='string'&&job.handle_id.length>=1,'durable job handle invalid');
|
|
27
|
+
insist(Number.isInteger(job.pid)&&job.pid>0,'durable job pid invalid');
|
|
28
|
+
insist(typeof job.creation_id==='string'&&job.creation_id.length>=1,'durable job creation identity invalid');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function validateState(value){
|
|
32
|
+
insist(value&&typeof value==='object'&&!Array.isArray(value)&&value.schema===SCHEMA,'durable job state schema mismatch');
|
|
33
|
+
insist(value.jobs&&typeof value.jobs==='object'&&!Array.isArray(value.jobs),'durable jobs invalid');
|
|
34
|
+
for(const [id,job] of Object.entries(value.jobs))validateJob(job,id);
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
async function loadState(file){
|
|
38
|
+
try{
|
|
39
|
+
const st=await lstat(file);insist(st.isFile()&&!st.isSymbolicLink(),'durable job state must be real file');
|
|
40
|
+
let value;try{value=JSON.parse(await readFile(file,'utf8'));}catch(error){throw new Error(`durable job state JSON invalid: ${error.message}`);}
|
|
41
|
+
return validateState(value);
|
|
42
|
+
}catch(error){
|
|
43
|
+
if(error?.code!=='ENOENT')throw error;
|
|
44
|
+
return {schema:SCHEMA,jobs:{},updated_at_utc:now()};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function descriptor(provider){
|
|
48
|
+
insist(provider&&typeof provider.descriptor==='function'&&typeof provider.start==='function'&&typeof provider.reattach==='function'&&
|
|
49
|
+
typeof provider.status==='function'&&typeof provider.terminate==='function','durable job provider contract invalid');
|
|
50
|
+
const d=provider.descriptor();
|
|
51
|
+
insist(d&&['external-supervisor','controller-local'].includes(d.kind),'durable job provider kind invalid');
|
|
52
|
+
insist(typeof d.instance_id==='string'&&d.instance_id.length>=1&&d.instance_id.length<=256,'durable job provider instance invalid');
|
|
53
|
+
return d;
|
|
54
|
+
}
|
|
55
|
+
function validHandle(handle,profileId){
|
|
56
|
+
return handle&&typeof handle.handle_id==='string'&&handle.handle_id.length>=1&&Number.isInteger(handle.pid)&&handle.pid>0&&
|
|
57
|
+
typeof handle.creation_id==='string'&&handle.creation_id.length>=1&&handle.profile_id===profileId;
|
|
58
|
+
}
|
|
59
|
+
function publicJob(job){return structuredClone(job);}
|
|
60
|
+
|
|
61
|
+
export async function createDurableJobSupervisorR44({stateDir,provider,ownerInstanceId}={}){
|
|
62
|
+
insist(typeof stateDir==='string'&&path.isAbsolute(stateDir),'durable job stateDir must be absolute');
|
|
63
|
+
insist(typeof ownerInstanceId==='string'&&ownerInstanceId.length>=1&&ownerInstanceId.length<=256,'durable job ownerInstanceId invalid');
|
|
64
|
+
const providerDesc=descriptor(provider);
|
|
65
|
+
await mkdir(stateDir,{recursive:true});const root=await realpath(stateDir),stateFile=path.join(root,'jobs.json');
|
|
66
|
+
let state=await loadState(stateFile);
|
|
67
|
+
let dirty=false;
|
|
68
|
+
for(const job of Object.values(state.jobs)){
|
|
69
|
+
if(['terminated','failed','interrupted','orphaned'].includes(job.state))continue;
|
|
70
|
+
if(job.state==='starting'){
|
|
71
|
+
job.state='interrupted';job.error_code='owner_restart_before_handle_commit';job.updated_at_utc=now();dirty=true;continue;
|
|
72
|
+
}
|
|
73
|
+
if(job.lifetime!=='persistent'){
|
|
74
|
+
if(job.owner_instance_id!==ownerInstanceId){
|
|
75
|
+
job.state='interrupted';job.error_code='owner_instance_changed';job.updated_at_utc=now();dirty=true;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if(providerDesc.kind!=='external-supervisor'){
|
|
80
|
+
job.state='orphaned';job.error_code='persistent_provider_not_external';job.updated_at_utc=now();dirty=true;continue;
|
|
81
|
+
}
|
|
82
|
+
let live=null;try{live=await provider.reattach({handle_id:job.handle_id,pid:job.pid,creation_id:job.creation_id,profile_id:job.profile_id});}catch{}
|
|
83
|
+
if(!validHandle(live,job.profile_id)||live.handle_id!==job.handle_id||live.pid!==job.pid||live.creation_id!==job.creation_id||live.state!=='running'){
|
|
84
|
+
job.state='orphaned';job.error_code='persistent_identity_mismatch';job.updated_at_utc=now();dirty=true;continue;
|
|
85
|
+
}
|
|
86
|
+
job.owner_instance_id=ownerInstanceId;job.provider_instance_id=providerDesc.instance_id;job.recovered=true;job.updated_at_utc=now();dirty=true;
|
|
87
|
+
}
|
|
88
|
+
if(dirty){state.updated_at_utc=now();await atomicJson(stateFile,state);}
|
|
89
|
+
else{try{await lstat(stateFile);}catch(error){if(error?.code==='ENOENT')await atomicJson(stateFile,state);else throw error;}}
|
|
90
|
+
|
|
91
|
+
let lock=Promise.resolve();
|
|
92
|
+
const withLock=fn=>{const run=lock.then(fn,fn);lock=run.catch(()=>{});return run;};
|
|
93
|
+
const persist=async()=>{state.updated_at_utc=now();await atomicJson(stateFile,state);};
|
|
94
|
+
|
|
95
|
+
async function start({lifetime,profileId,rootId,cwd='.',args=[]}={}){
|
|
96
|
+
insist(LIFETIMES.has(lifetime),'durable job lifetime invalid');
|
|
97
|
+
insist(ID.test(profileId??'')&&ID.test(rootId??''),'durable job profile/root invalid');
|
|
98
|
+
insist(typeof cwd==='string'&&cwd.length>=1&&cwd.length<=512,'durable job cwd invalid');
|
|
99
|
+
insist(Array.isArray(args)&&args.length<=64&&args.every(v=>typeof v==='string'&&v.length<=4096),'durable job args invalid');
|
|
100
|
+
if(lifetime==='persistent')insist(providerDesc.kind==='external-supervisor','persistent lifetime requires external supervisor');
|
|
101
|
+
const jobId=randomBytes(32).toString('hex');
|
|
102
|
+
await withLock(async()=>{
|
|
103
|
+
state.jobs[jobId]={job_id:jobId,lifetime,profile_id:profileId,root_id:rootId,cwd,args_sha256:null,state:'starting',
|
|
104
|
+
owner_instance_id:ownerInstanceId,provider_kind:providerDesc.kind,provider_instance_id:providerDesc.instance_id,recovered:false,
|
|
105
|
+
created_at_utc:now(),updated_at_utc:now()};
|
|
106
|
+
await persist();
|
|
107
|
+
});
|
|
108
|
+
let handle;
|
|
109
|
+
try{handle=await provider.start({job_id:jobId,lifetime,profile_id:profileId,root_id:rootId,cwd,args});}
|
|
110
|
+
catch(error){
|
|
111
|
+
return withLock(async()=>{const job=state.jobs[jobId];job.state='failed';job.error_code='provider_start_failed';job.error=String(error?.message??error);job.updated_at_utc=now();await persist();return publicJob(job);});
|
|
112
|
+
}
|
|
113
|
+
insist(validHandle(handle,profileId),'durable job provider returned invalid handle');
|
|
114
|
+
return withLock(async()=>{
|
|
115
|
+
const job=state.jobs[jobId];insist(job.state==='starting','durable job start state changed before handle commit');
|
|
116
|
+
Object.assign(job,{handle_id:handle.handle_id,pid:handle.pid,creation_id:handle.creation_id,state:handle.state==='running'?'running':'failed',updated_at_utc:now()});
|
|
117
|
+
if(job.state==='failed')job.error_code='provider_not_running_after_start';
|
|
118
|
+
await persist();return publicJob(job);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
async function status({jobId}={}){
|
|
122
|
+
insist(HEX64.test(jobId??''),'durable job id invalid');
|
|
123
|
+
const job=state.jobs[jobId];insist(job,'unknown durable job');
|
|
124
|
+
if(job.state==='running'&&job.lifetime==='persistent'){
|
|
125
|
+
let live=null;try{live=await provider.status({handle_id:job.handle_id,pid:job.pid,creation_id:job.creation_id,profile_id:job.profile_id});}catch{}
|
|
126
|
+
if(!validHandle(live,job.profile_id)||live.handle_id!==job.handle_id||live.pid!==job.pid||live.creation_id!==job.creation_id){
|
|
127
|
+
await withLock(async()=>{job.state='orphaned';job.error_code='persistent_identity_mismatch';job.updated_at_utc=now();await persist();});
|
|
128
|
+
}else if(live.state==='terminated'){
|
|
129
|
+
await withLock(async()=>{job.state='terminated';job.updated_at_utc=now();await persist();});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return publicJob(job);
|
|
133
|
+
}
|
|
134
|
+
async function terminate({jobId}={}){
|
|
135
|
+
insist(HEX64.test(jobId??''),'durable job id invalid');
|
|
136
|
+
const job=state.jobs[jobId];insist(job,'unknown durable job');
|
|
137
|
+
if(['terminated','failed','interrupted','orphaned'].includes(job.state))return publicJob(job);
|
|
138
|
+
insist(job.handle_id&&job.pid&&job.creation_id,'durable job has no committed handle');
|
|
139
|
+
const live=await provider.terminate({handle_id:job.handle_id,pid:job.pid,creation_id:job.creation_id,profile_id:job.profile_id});
|
|
140
|
+
insist(validHandle(live,job.profile_id)&&live.handle_id===job.handle_id&&live.pid===job.pid&&live.creation_id===job.creation_id,'durable job terminate identity mismatch');
|
|
141
|
+
return withLock(async()=>{job.state='terminated';job.updated_at_utc=now();await persist();return publicJob(job);});
|
|
142
|
+
}
|
|
143
|
+
async function list(){return {status:'ok',jobs:Object.values(state.jobs).map(publicJob).sort((a,b)=>a.job_id.localeCompare(b.job_id))};}
|
|
144
|
+
return Object.freeze({start,status,terminate,list,state_file:stateFile,provider:providerDesc});
|
|
145
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, open, readFile, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import net from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const HEX64=/^[0-9a-f]{64}$/;
|
|
8
|
+
const ID=/^[A-Za-z0-9._-]{1,64}$/;
|
|
9
|
+
const sha=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
function insist(ok,message){if(!ok)throw new Error(message);}
|
|
11
|
+
async function atomicJson(file,value){
|
|
12
|
+
const bytes=Buffer.from(`${JSON.stringify(value,null,2)}\n`,'utf8');
|
|
13
|
+
const temp=`${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
14
|
+
const h=await open(temp,'wx');try{await h.writeFile(bytes);await h.sync();}finally{await h.close();}
|
|
15
|
+
await rename(temp,file);
|
|
16
|
+
}
|
|
17
|
+
async function hashFile(file,label){
|
|
18
|
+
const st=await lstat(file);insist(st.isFile()&&!st.isSymbolicLink(),`${label} must be real file`);
|
|
19
|
+
return sha(await readFile(file));
|
|
20
|
+
}
|
|
21
|
+
function inside(root,target){const rel=path.relative(root,target);return rel===''||(!rel.startsWith('..'+path.sep)&&rel!=='..'&&!path.isAbsolute(rel));}
|
|
22
|
+
|
|
23
|
+
export async function createExternalJobProviderR44({
|
|
24
|
+
stateDir,nodeExecutable,nodeExecutableSha256,workerPath,workerSha256,roots={},profiles={}
|
|
25
|
+
}={}){
|
|
26
|
+
insist(typeof stateDir==='string'&&path.isAbsolute(stateDir),'external supervisor stateDir must be absolute');
|
|
27
|
+
await mkdir(stateDir,{recursive:true});const root=await realpath(stateDir);
|
|
28
|
+
insist(typeof nodeExecutable==='string'&&path.isAbsolute(nodeExecutable)&&HEX64.test(nodeExecutableSha256??''),'external supervisor Node identity invalid');
|
|
29
|
+
insist(await hashFile(nodeExecutable,'Node executable')===nodeExecutableSha256,'external supervisor Node executable hash mismatch');
|
|
30
|
+
insist(typeof workerPath==='string'&&path.isAbsolute(workerPath)&&HEX64.test(workerSha256??''),'external supervisor worker identity invalid');
|
|
31
|
+
insist(await hashFile(workerPath,'worker')===workerSha256,'external supervisor worker hash mismatch');
|
|
32
|
+
const normalizedRoots={};
|
|
33
|
+
for(const [id,value] of Object.entries(roots)){
|
|
34
|
+
insist(ID.test(id)&&typeof value==='string'&&path.isAbsolute(value),'external supervisor root invalid');
|
|
35
|
+
const real=await realpath(value),st=await lstat(real);insist(st.isDirectory()&&!st.isSymbolicLink(),'external supervisor root must be real directory');normalizedRoots[id]=real;
|
|
36
|
+
}
|
|
37
|
+
const normalizedProfiles={};
|
|
38
|
+
for(const [id,p] of Object.entries(profiles)){
|
|
39
|
+
insist(ID.test(id)&&p&&typeof p==='object','external supervisor profile invalid');
|
|
40
|
+
insist(typeof p.executable==='string'&&path.isAbsolute(p.executable)&&HEX64.test(p.executable_sha256??''),'external supervisor profile executable invalid');
|
|
41
|
+
insist(await hashFile(p.executable,`profile ${id} executable`)===p.executable_sha256,`external supervisor profile ${id} executable hash mismatch`);
|
|
42
|
+
insist(Array.isArray(p.root_ids)&&p.root_ids.length>=1&&p.root_ids.every(r=>Object.hasOwn(normalizedRoots,r)),'external supervisor profile roots invalid');
|
|
43
|
+
insist(Array.isArray(p.prefix_args)&&p.prefix_args.length<=32&&p.prefix_args.every(v=>typeof v==='string'&&v.length<=8192),'external supervisor prefix args invalid');
|
|
44
|
+
insist(Number.isInteger(p.max_args)&&p.max_args>=0&&p.max_args<=64&&Number.isInteger(p.max_arg_bytes)&&p.max_arg_bytes>=1&&p.max_arg_bytes<=8192,'external supervisor profile bounds invalid');
|
|
45
|
+
normalizedProfiles[id]={...p};
|
|
46
|
+
}
|
|
47
|
+
const authPath=path.join(root,'supervisor-auth.json'),endpointPath=path.join(root,'supervisor-endpoint.json');
|
|
48
|
+
let token;
|
|
49
|
+
try{
|
|
50
|
+
const st=await lstat(authPath);insist(st.isFile()&&!st.isSymbolicLink(),'external supervisor auth must be real file');
|
|
51
|
+
const auth=JSON.parse(await readFile(authPath,'utf8'));insist(auth?.schema==='deadbyte.job-supervisor-auth.v1'&&HEX64.test(auth.token??''),'external supervisor auth invalid');token=auth.token;
|
|
52
|
+
}catch(error){
|
|
53
|
+
if(error?.code!=='ENOENT')throw error;
|
|
54
|
+
token=randomBytes(32).toString('hex');await atomicJson(authPath,{schema:'deadbyte.job-supervisor-auth.v1',token,created_at_utc:new Date().toISOString()});
|
|
55
|
+
}
|
|
56
|
+
async function endpoint(){
|
|
57
|
+
const st=await lstat(endpointPath);insist(st.isFile()&&!st.isSymbolicLink(),'external supervisor endpoint must be real file');
|
|
58
|
+
const value=JSON.parse(await readFile(endpointPath,'utf8'));
|
|
59
|
+
insist(value?.schema==='deadbyte.job-supervisor-endpoint.v1'&&value.host==='127.0.0.1'&&Number.isInteger(value.port)&&value.port>=1&&value.port<=65535&&HEX64.test(value.instance_id??''),'external supervisor endpoint invalid');
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
async function rpcWith(ep,op,args={},timeoutMs=5000){
|
|
63
|
+
return new Promise((resolve,reject)=>{
|
|
64
|
+
const socket=net.createConnection({host:ep.host,port:ep.port});let buffer='';let settled=false;
|
|
65
|
+
const finish=(error,value)=>{if(settled)return;settled=true;clearTimeout(timer);socket.destroy();error?reject(error):resolve(value);};
|
|
66
|
+
const timer=setTimeout(()=>finish(new Error('external supervisor RPC timeout')),timeoutMs);
|
|
67
|
+
socket.setEncoding('utf8');
|
|
68
|
+
socket.once('error',error=>finish(error));
|
|
69
|
+
socket.on('data',chunk=>{
|
|
70
|
+
buffer+=chunk;if(Buffer.byteLength(buffer,'utf8')>1024*1024){finish(new Error('external supervisor RPC response limit exceeded'));return;}
|
|
71
|
+
const nl=buffer.indexOf('\n');if(nl<0)return;
|
|
72
|
+
let response;try{response=JSON.parse(buffer.slice(0,nl));}catch(error){finish(error);return;}
|
|
73
|
+
if(response?.ok!==true){finish(new Error(response?.error??'external supervisor RPC failed'));return;}
|
|
74
|
+
finish(null,response.result);
|
|
75
|
+
});
|
|
76
|
+
socket.once('connect',()=>socket.write(JSON.stringify({token,op,args})+'\n'));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function tryHello(){
|
|
80
|
+
try{const ep=await endpoint();const hello=await rpcWith(ep,'hello',{},1500);if(hello?.instance_id===ep.instance_id)return {ep,hello};}catch{}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
let live=await tryHello();
|
|
84
|
+
if(!live){
|
|
85
|
+
await rm(endpointPath,{force:true}).catch(()=>{});
|
|
86
|
+
const child=spawn(nodeExecutable,[workerPath,'--state-dir',root],{cwd:path.dirname(workerPath),windowsHide:true,shell:false,detached:true,stdio:'ignore'});
|
|
87
|
+
child.unref();
|
|
88
|
+
const deadline=Date.now()+8000;
|
|
89
|
+
while(Date.now()<deadline){await new Promise(resolve=>setTimeout(resolve,50));live=await tryHello();if(live)break;}
|
|
90
|
+
insist(live,'external supervisor failed to become ready');
|
|
91
|
+
}
|
|
92
|
+
let descriptorValue={kind:'external-supervisor',instance_id:live.hello.instance_id};
|
|
93
|
+
|
|
94
|
+
async function call(op,args={},timeout=5000){
|
|
95
|
+
let ep=await endpoint();
|
|
96
|
+
try{return await rpcWith(ep,op,args,timeout);}
|
|
97
|
+
catch(error){
|
|
98
|
+
const refreshed=await tryHello();
|
|
99
|
+
if(!refreshed)throw error;
|
|
100
|
+
descriptorValue={kind:'external-supervisor',instance_id:refreshed.hello.instance_id};
|
|
101
|
+
return rpcWith(refreshed.ep,op,args,timeout);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function resolveSpec(spec){
|
|
105
|
+
insist(spec&&ID.test(spec.profile_id??'')&&ID.test(spec.root_id??''),'external job spec identity invalid');
|
|
106
|
+
const profile=normalizedProfiles[spec.profile_id];insist(profile,'external job profile unknown');
|
|
107
|
+
insist(profile.root_ids.includes(spec.root_id),'external job root denied by profile');
|
|
108
|
+
insist(Array.isArray(spec.args)&&spec.args.length<=profile.max_args&&spec.args.every(v=>typeof v==='string'&&Buffer.byteLength(v,'utf8')<=profile.max_arg_bytes),'external job args exceed bounds');
|
|
109
|
+
const rootPath=normalizedRoots[spec.root_id];
|
|
110
|
+
insist(typeof spec.cwd==='string'&&spec.cwd.length>=1&&spec.cwd.length<=512&&!spec.cwd.includes('\0'),'external job cwd invalid');
|
|
111
|
+
const lexical=path.resolve(rootPath,spec.cwd);insist(inside(rootPath,lexical),'external job cwd escapes root');
|
|
112
|
+
const cwd=await realpath(lexical);insist(inside(rootPath,cwd),'external job cwd symlink escapes root');
|
|
113
|
+
insist(await hashFile(profile.executable,`profile ${spec.profile_id} executable`)===profile.executable_sha256,'external job executable hash changed');
|
|
114
|
+
return {job_id:spec.job_id,profile_id:spec.profile_id,executable:profile.executable,executable_sha256:profile.executable_sha256,
|
|
115
|
+
argv:[...profile.prefix_args,...spec.args],cwd};
|
|
116
|
+
}
|
|
117
|
+
return Object.freeze({
|
|
118
|
+
descriptor:()=>({...descriptorValue}),
|
|
119
|
+
start:async spec=>call('start',await resolveSpec(spec),8000),
|
|
120
|
+
reattach:async handle=>call('reattach',handle),
|
|
121
|
+
status:async handle=>call('status',handle),
|
|
122
|
+
terminate:async handle=>call('terminate',handle,5000),
|
|
123
|
+
shutdown:async()=>{try{return await call('shutdown',{},3000);}finally{await new Promise(resolve=>setTimeout(resolve,100));}},
|
|
124
|
+
state_dir:root
|
|
125
|
+
});
|
|
126
|
+
}
|