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.
Files changed (105) hide show
  1. package/AGENTS.md +11 -18
  2. package/CONTEXT.md +3 -3
  3. package/MANIFEST.SHA256 +104 -53
  4. package/R43-PARENT.json +20 -0
  5. package/R44-DONORS.json +99 -0
  6. package/R44-PARENT.json +18 -0
  7. package/README.txt +7 -2
  8. package/controller/README.TXT +4 -5
  9. package/controller/deadbyte-controller.ps1 +1 -1
  10. package/docs/ARCHITECTURE.md +8 -1
  11. package/docs/R44-ARCHITECTURE.md +37 -0
  12. package/package.json +13 -8
  13. package/scripts/deferred-slot-operation-r43.mjs +126 -0
  14. package/scripts/deferred-slot-operation-r44.mjs +126 -0
  15. package/scripts/final-closure-r43.mjs +115 -0
  16. package/scripts/final-closure-r44.mjs +121 -0
  17. package/scripts/final-closure-verify-r43.mjs +213 -0
  18. package/scripts/final-closure-verify-r44.mjs +220 -0
  19. package/scripts/finalize-r43.mjs +217 -0
  20. package/scripts/finalize-r44.mjs +217 -0
  21. package/scripts/full-regression-r43.mjs +20 -0
  22. package/scripts/full-regression-r44.mjs +20 -0
  23. package/scripts/gate-windows-r43.ps1 +36 -0
  24. package/scripts/gate-windows-r44.ps1 +36 -0
  25. package/scripts/release-parity-r43.mjs +218 -0
  26. package/scripts/release-parity-r44.mjs +242 -0
  27. package/scripts/release-parity-tests-r43.ps1 +8 -0
  28. package/scripts/release-parity-tests-r44.ps1 +8 -0
  29. package/scripts/verify-r43-parent.mjs +126 -0
  30. package/scripts/verify-r44-parent.mjs +142 -0
  31. package/scripts/windows-gate-evidence-r43.mjs +55 -0
  32. package/scripts/windows-gate-evidence-r44.mjs +55 -0
  33. package/src/agent-backend-r44.mjs +254 -0
  34. package/src/autonomous-output-contracts.mjs +1 -1
  35. package/src/autonomous-policy.mjs +41 -4
  36. package/src/autonomous-runtime.mjs +25 -12
  37. package/src/autonomous-supervisor-host.mjs +12 -2
  38. package/src/capability-ledger.mjs +2 -1
  39. package/src/codex-app-server-r44.mjs +154 -0
  40. package/src/coding-plane.mjs +66 -19
  41. package/src/durable-job-r44.mjs +145 -0
  42. package/src/external-job-provider-r44.mjs +126 -0
  43. package/src/host-plane.mjs +27 -6
  44. package/src/job-supervisor-host-r44.mjs +112 -0
  45. package/src/machine-fs-smoke-client.mjs +1 -1
  46. package/src/machine-fs.mjs +21 -10
  47. package/src/mcp-host-smoke-client.mjs +1 -1
  48. package/src/mcp-server.mjs +20 -3
  49. package/src/mcp-smoke-client.mjs +1 -1
  50. package/src/native-diff-v2.mjs +3 -3
  51. package/src/native-diff.mjs +3 -2
  52. package/src/observation-smoke-client.mjs +1 -1
  53. package/src/operator-surface.mjs +2 -2
  54. package/src/process-policy.mjs +19 -0
  55. package/src/process-runtime.mjs +5 -2
  56. package/src/process-smoke-client.mjs +2 -2
  57. package/src/provider-circuit.mjs +8 -2
  58. package/src/r44-compact-disarmed-probe.mjs +74 -0
  59. package/src/r44-full-surface-probe.mjs +34 -0
  60. package/src/remote-device-r44.mjs +205 -0
  61. package/src/remote-mcp-autonomous-smoke-client.mjs +4 -4
  62. package/src/remote-mcp-r44-parity-client.mjs +203 -0
  63. package/src/remote-mcp-smoke-client.mjs +7 -4
  64. package/src/runtime-update.mjs +64 -31
  65. package/src/skill-mcp-tools-r44.mjs +37 -0
  66. package/src/skill-plane-r44.mjs +203 -0
  67. package/src/tunnel-stdio-compat-smoke.mjs +1 -1
  68. package/src/version.mjs +1 -1
  69. package/test/agent-backend-r44.test.mjs +115 -0
  70. package/test/agent-runtime.test.mjs +3 -3
  71. package/test/autonomous-loop.test.mjs +97 -11
  72. package/test/autonomous-runtime.test.mjs +2 -2
  73. package/test/capability-ledger-r36.test.mjs +3 -3
  74. package/test/cli-entrypoint.test.mjs +1 -1
  75. package/test/codex-app-server-r44.test.mjs +115 -0
  76. package/test/codex-autonomous-r44.test.mjs +87 -0
  77. package/test/coding-plane.test.mjs +2 -2
  78. package/test/coding-runtime.test.mjs +36 -1
  79. package/test/compact-catalog-r36.test.mjs +3 -3
  80. package/test/compact-max-catalog-r40.test.mjs +4 -2
  81. package/test/compact-max-catalog-r44.test.mjs +97 -0
  82. package/test/core.test.mjs +1 -1
  83. package/test/durable-job-r44.test.mjs +75 -0
  84. package/test/external-job-provider-r44.test.mjs +46 -0
  85. package/test/full-max-catalog-r40.test.mjs +5 -3
  86. package/test/full-max-catalog-r44.test.mjs +91 -0
  87. package/test/host-plane.test.mjs +15 -1
  88. package/test/native-diff.test.mjs +28 -0
  89. package/test/output-schema.test.mjs +2 -2
  90. package/test/process-policy.test.mjs +22 -1
  91. package/test/prompts-resources.test.mjs +1 -1
  92. package/test/r33-closeout-regression.test.mjs +13 -30
  93. package/test/r33-finalization.test.mjs +7 -7
  94. package/test/r43-finalization.test.mjs +31 -0
  95. package/test/r43-finalizer.test.mjs +34 -0
  96. package/test/r43-parent.test.mjs +29 -0
  97. package/test/r44-finalization.test.mjs +58 -0
  98. package/test/r44-finalizer.test.mjs +48 -0
  99. package/test/r44-parent.test.mjs +25 -0
  100. package/test/release-version.test.mjs +53 -19
  101. package/test/remote-device-r44.test.mjs +104 -0
  102. package/test/runtime-self-update.test.mjs +42 -0
  103. package/test/skill-mcp-r44.test.mjs +38 -0
  104. package/test/skill-plane-r44.test.mjs +93 -0
  105. 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
+ }
@@ -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
- const { lexical } = await resolveCodingWritable(policy, lease, rootId, relativePath);
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, lease, {
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, lease, {
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
- const { after } = await atomicWrite(lexical, nextBytes, expectedSha256);
467
- const signed = await signCodingReceipt(policy, signingKeyPath, lease, {
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
- const grantBound = Number.isFinite(lease.remaining_ms) ? lease.remaining_ms : command.timeout_ms;
502
- const effectiveTimeout = Math.max(100, Math.min(requestedTimeout, command.timeout_ms, grantBound));
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'); }, effectiveTimeout);
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: effectiveTimeout, elapsed_ms: Math.round((performance.now() - startMs) * 1000) / 1000,
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, receiptId = null, requestSha256 = 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
- warmups = 1, iterations = 5, baselineMedianMs = null, maxRegressionPct = 5, receiptId = null, requestSha256 = null }) {
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
+ }