@mogragsigma/dsh-dual-host-plugin 0.0.0-stage → 0.33.2
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/LICENSE +58 -0
- package/LICENSE.en.md +94 -0
- package/README.md +88 -2
- package/bundle/ASSET_NOTICE.md +19 -0
- package/bundle/LICENSE +58 -0
- package/bundle/LICENSE.en.md +94 -0
- package/bundle/README.md +75 -0
- package/bundle/THIRD_PARTY_NOTICES.md +94 -0
- package/bundle/a2a-dispatches.js +38 -0
- package/bundle/a2a-endpoint.js +112 -0
- package/bundle/a2a-tools.js +11 -0
- package/bundle/a2a-worker.js +63 -0
- package/bundle/approval-auth.js +5 -0
- package/bundle/approval-chat.js +150 -0
- package/bundle/approval-feishu.js +52 -0
- package/bundle/approval-guidance.js +68 -0
- package/bundle/approval-http.js +8 -0
- package/bundle/approval-ledger.js +142 -0
- package/bundle/approval-local-relay.js +86 -0
- package/bundle/approval-receipts.js +62 -0
- package/bundle/approval-reference.js +33 -0
- package/bundle/approval-semantic-model.js +26 -0
- package/bundle/approval-semantics.js +51 -0
- package/bundle/approval-ui/app.js +1 -0
- package/bundle/approval-ui/index.html +1 -0
- package/bundle/approval-ui/style.css +1 -0
- package/bundle/approval-workbench.js +45 -0
- package/bundle/artifact-media.js +34 -0
- package/bundle/bounded-text.js +44 -0
- package/bundle/compatibility.json +980 -0
- package/bundle/cordis.patch.yml +5 -0
- package/bundle/delivery-approval.js +133 -0
- package/bundle/delivery-artifacts.js +52 -0
- package/bundle/delivery-commit.js +125 -0
- package/bundle/delivery-contract.js +42 -0
- package/bundle/delivery-git-read.js +217 -0
- package/bundle/delivery-index.js +23 -0
- package/bundle/delivery-journal.js +207 -0
- package/bundle/delivery-object-plan.js +138 -0
- package/bundle/delivery-preview.js +153 -0
- package/bundle/delivery-process-receipts.js +90 -0
- package/bundle/delivery-process.js +46 -0
- package/bundle/delivery-process.py +228 -0
- package/bundle/delivery-push-approval.js +89 -0
- package/bundle/delivery-push-plan.js +98 -0
- package/bundle/delivery-push-recovery.js +32 -0
- package/bundle/delivery-push.js +83 -0
- package/bundle/delivery-recovery.js +143 -0
- package/bundle/delivery-runtime.js +182 -0
- package/bundle/delivery-ssh.js +64 -0
- package/bundle/delivery-state.js +106 -0
- package/bundle/development-attention.js +35 -0
- package/bundle/development-bridge.js +136 -0
- package/bundle/development-command-review.js +110 -0
- package/bundle/development-container.js +110 -0
- package/bundle/development-contract.js +17 -0
- package/bundle/development-files.js +101 -0
- package/bundle/development-gate.js +236 -0
- package/bundle/development-model-budget.js +48 -0
- package/bundle/development-peer.js +155 -0
- package/bundle/development-plan.js +43 -0
- package/bundle/development-reconciliation.js +62 -0
- package/bundle/development-review.js +81 -0
- package/bundle/development-rules/AGENTS.md +51 -0
- package/bundle/development-runtime.js +68 -0
- package/bundle/development-source.js +48 -0
- package/bundle/development-test-runner.js +13 -0
- package/bundle/development-tools.js +45 -0
- package/bundle/development-worker-review.js +110 -0
- package/bundle/development-worker.js +68 -0
- package/bundle/development-workspace.js +54 -0
- package/bundle/diagnostics.js +78 -0
- package/bundle/distribution-lock.json +773 -0
- package/bundle/drained-process.js +26 -0
- package/bundle/durable-inbox.js +294 -0
- package/bundle/feishu-policy.js +24 -0
- package/bundle/gateway-maintenance.js +55 -0
- package/bundle/global-rules/AGENTS.md +83 -0
- package/bundle/im-delivery.js +60 -0
- package/bundle/index.js +445 -0
- package/bundle/intervention-actions.js +135 -0
- package/bundle/intervention-job-control.js +28 -0
- package/bundle/intervention-state.js +43 -0
- package/bundle/job-version.js +15 -0
- package/bundle/legacy-feishu-adapter.js +89 -0
- package/bundle/link-contract.js +80 -0
- package/bundle/maintenance-process-identity.js +93 -0
- package/bundle/model-selection.js +71 -0
- package/bundle/native-context.js +14 -0
- package/bundle/native-entry-admission.js +2 -0
- package/bundle/native-entry.js +58 -0
- package/bundle/native-file-reference.js +32 -0
- package/bundle/native-owner-origin.js +12 -0
- package/bundle/native-rules/AGENTS.md +199 -0
- package/bundle/native-tools.js +108 -0
- package/bundle/notification-artifacts.js +70 -0
- package/bundle/notification-contract.js +29 -0
- package/bundle/notification-defaults.js +81 -0
- package/bundle/notification-discovery.js +65 -0
- package/bundle/notification-endpoint.js +54 -0
- package/bundle/notification-feishu.js +51 -0
- package/bundle/notification-media-endpoint.js +94 -0
- package/bundle/notification-review.js +27 -0
- package/bundle/notification-targets.js +54 -0
- package/bundle/onboarding-activity.js +66 -0
- package/bundle/onboarding-agent-hold.js +94 -0
- package/bundle/onboarding-boundary.js +20 -0
- package/bundle/onboarding-cloud-contract.js +29 -0
- package/bundle/onboarding-cloud-file-review.js +58 -0
- package/bundle/onboarding-cloud-files.js +100 -0
- package/bundle/onboarding-cloud-im.js +34 -0
- package/bundle/onboarding-cloud-model-probe.js +53 -0
- package/bundle/onboarding-cloud-models.js +68 -0
- package/bundle/onboarding-cloud-operation.js +276 -0
- package/bundle/onboarding-cloud-package.js +38 -0
- package/bundle/onboarding-cloud-repositories.js +89 -0
- package/bundle/onboarding-cloud-retirement.js +59 -0
- package/bundle/onboarding-cloud-task-runtime.js +90 -0
- package/bundle/onboarding-cloud-web.js +85 -0
- package/bundle/onboarding-cloud.js +110 -0
- package/bundle/onboarding-coordinated-hold.js +94 -0
- package/bundle/onboarding-exclusive-rename.js +16 -0
- package/bundle/onboarding-existing-host.js +63 -0
- package/bundle/onboarding-existing-setup.js +123 -0
- package/bundle/onboarding-file-contract.js +31 -0
- package/bundle/onboarding-file-review-contract.js +38 -0
- package/bundle/onboarding-file-transfer.js +133 -0
- package/bundle/onboarding-handoff-remote.js +98 -0
- package/bundle/onboarding-handoff.js +112 -0
- package/bundle/onboarding-host-stop-observation.js +44 -0
- package/bundle/onboarding-host.js +254 -0
- package/bundle/onboarding-identity.js +9 -0
- package/bundle/onboarding-im-maintenance-native.js +30 -0
- package/bundle/onboarding-im-maintenance.js +70 -0
- package/bundle/onboarding-im.js +31 -0
- package/bundle/onboarding-inspection-guard.js +15 -0
- package/bundle/onboarding-install-fs.js +58 -0
- package/bundle/onboarding-installer.js +157 -0
- package/bundle/onboarding-local-pair-native.js +21 -0
- package/bundle/onboarding-local-pair.js +99 -0
- package/bundle/onboarding-maintenance-fs.js +69 -0
- package/bundle/onboarding-maintenance-plan.js +213 -0
- package/bundle/onboarding-maintenance-record.js +169 -0
- package/bundle/onboarding-maintenance.js +163 -0
- package/bundle/onboarding-model-probe.js +119 -0
- package/bundle/onboarding-models.js +107 -0
- package/bundle/onboarding-native-host.js +101 -0
- package/bundle/onboarding-native-probe.js +56 -0
- package/bundle/onboarding-native-runner.js +29 -0
- package/bundle/onboarding-node-bootstrap.py +678 -0
- package/bundle/onboarding-node.js +77 -0
- package/bundle/onboarding-notification-defaults.js +93 -0
- package/bundle/onboarding-package-source.js +33 -0
- package/bundle/onboarding-paths.js +58 -0
- package/bundle/onboarding-peer-binding.js +61 -0
- package/bundle/onboarding-peer-contract.js +12 -0
- package/bundle/onboarding-plugin-repair.js +116 -0
- package/bundle/onboarding-plugin-version.js +143 -0
- package/bundle/onboarding-remote-control.js +140 -0
- package/bundle/onboarding-remote-probe.js +40 -0
- package/bundle/onboarding-repositories.js +88 -0
- package/bundle/onboarding-repository.js +49 -0
- package/bundle/onboarding-runtime-activation.js +83 -0
- package/bundle/onboarding-runtime-artifacts-client.js +87 -0
- package/bundle/onboarding-runtime-artifacts.js +126 -0
- package/bundle/onboarding-runtime-extension.js +400 -0
- package/bundle/onboarding-runtime-inventory.js +88 -0
- package/bundle/onboarding-runtime-program.js +42 -0
- package/bundle/onboarding-runtime-reclaim-client.js +94 -0
- package/bundle/onboarding-runtime-reclaim-worker.js +63 -0
- package/bundle/onboarding-runtime-reclaim.js +132 -0
- package/bundle/onboarding-runtime-repair-client.js +50 -0
- package/bundle/onboarding-runtime-repair.js +88 -0
- package/bundle/onboarding-runtime-stop.js +90 -0
- package/bundle/onboarding-runtime-uninstall-client.js +88 -0
- package/bundle/onboarding-runtime-uninstall.js +167 -0
- package/bundle/onboarding-runtime-version-client.js +138 -0
- package/bundle/onboarding-runtime-version.js +145 -0
- package/bundle/onboarding-schema.js +42 -0
- package/bundle/onboarding-self-disable.js +3 -0
- package/bundle/onboarding-self-lifecycle.js +95 -0
- package/bundle/onboarding-self-maintenance.js +52 -0
- package/bundle/onboarding-self-remove.js +3 -0
- package/bundle/onboarding-self-stop.js +85 -0
- package/bundle/onboarding-ssh-forward.js +48 -0
- package/bundle/onboarding-ssh.js +105 -0
- package/bundle/onboarding-state.js +95 -0
- package/bundle/onboarding-task-runtime-contract.js +38 -0
- package/bundle/onboarding-task-runtime-native.js +80 -0
- package/bundle/onboarding-task-runtime.js +66 -0
- package/bundle/onboarding-token-plan.js +52 -0
- package/bundle/onboarding-version-activation-client.js +48 -0
- package/bundle/onboarding-version-candidate.js +96 -0
- package/bundle/onboarding-version-repair.js +128 -0
- package/bundle/package-lock.json +773 -0
- package/bundle/package.json +99 -0
- package/bundle/peer-file-fs.js +62 -0
- package/bundle/peer-file-migration.js +59 -0
- package/bundle/peer-file-review.js +107 -0
- package/bundle/peer-files.js +157 -0
- package/bundle/peer-mailbox.js +75 -0
- package/bundle/protocol.js +17 -0
- package/bundle/recovery.js +40 -0
- package/bundle/repository-catalog.js +85 -0
- package/bundle/repository-gitee.js +76 -0
- package/bundle/repository-managed.js +46 -0
- package/bundle/repository-writer.js +43 -0
- package/bundle/rules/AGENTS.md +22 -0
- package/bundle/runtime-im-lock.json +11577 -0
- package/bundle/runtime-im-package.json +18 -0
- package/bundle/runtime-lock.json +10152 -0
- package/bundle/runtime-package.json +17 -0
- package/bundle/scheduler-migration.js +45 -0
- package/bundle/search-contract.js +19 -0
- package/bundle/skills/dsh-dual-end/SKILL.md +12 -0
- package/bundle/skills/task-a2a/SKILL.md +55 -0
- package/bundle/skills/task-a2a-worker/SKILL.md +23 -0
- package/bundle/skills/task-approval/SKILL.md +128 -0
- package/bundle/skills/task-artifacts/SKILL.md +46 -0
- package/bundle/skills/task-development/SKILL.md +144 -0
- package/bundle/skills/task-diagnostics/SKILL.md +41 -0
- package/bundle/skills/task-direct-link/SKILL.md +143 -0
- package/bundle/skills/task-file-transfer/SKILL.md +107 -0
- package/bundle/skills/task-handoff/SKILL.md +23 -0
- package/bundle/skills/task-intake/SKILL.md +13 -0
- package/bundle/skills/task-notify/SKILL.md +102 -0
- package/bundle/skills/task-onboarding/SKILL.md +708 -0
- package/bundle/skills/task-recovery/SKILL.md +54 -0
- package/bundle/skills/task-repository/SKILL.md +145 -0
- package/bundle/skills/task-research/SKILL.md +43 -0
- package/bundle/skills/task-schedule/SKILL.md +79 -0
- package/bundle/skills/task-sidebar/SKILL.md +122 -0
- package/bundle/skills/task-workbench/SKILL.md +30 -0
- package/bundle/skills/task-worker/SKILL.md +88 -0
- package/bundle/source-search.js +89 -0
- package/bundle/source-web.js +17 -0
- package/bundle/ssh-connector.js +24 -0
- package/bundle/store.js +102 -0
- package/bundle/task-a2a.js +298 -0
- package/bundle/task-artifacts.js +179 -0
- package/bundle/task-board-a2a.js +55 -0
- package/bundle/task-board-development.js +83 -0
- package/bundle/task-board-planning.js +84 -0
- package/bundle/task-board.js +132 -0
- package/bundle/task-branch.js +2 -0
- package/bundle/task-catalog.js +77 -0
- package/bundle/task-context.js +40 -0
- package/bundle/task-envelope.js +96 -0
- package/bundle/task-interventions.js +54 -0
- package/bundle/task-jobs.js +156 -0
- package/bundle/task-notifications.js +132 -0
- package/bundle/task-results.js +16 -0
- package/bundle/task-scheduler.js +77 -0
- package/bundle/task-sources.js +101 -0
- package/bundle/task-trigger-time.js +46 -0
- package/bundle/task-triggers.js +157 -0
- package/bundle/task-worker.js +73 -0
- package/bundle/task-workflows.js +117 -0
- package/bundle/tools.js +13 -0
- package/bundle/workbench-assets/asset-provenance.json +26 -0
- package/bundle/workbench-assets/whale-ui-states.png +0 -0
- package/bundle/workbench-client.js +811 -0
- package/bundle/workbench-decisions.js +48 -0
- package/bundle/workbench-host.js +122 -0
- package/bundle/workbench-im-identity.js +51 -0
- package/bundle/workbench-proxy.js +127 -0
- package/bundle/worker-rules/AGENTS.md +84 -0
- package/bundle/worker-tools.js +13 -0
- package/compatibility.json +980 -0
- package/cordis.patch.yml +3 -0
- package/package.json +57 -4
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Private durable supervisor evidence. Not a signature or OS identity boundary:
|
|
2
|
+
// an administrator able to rewrite the journal and state directory remains trusted.
|
|
3
|
+
import {mkdirSync,lstatSync,realpathSync,openSync,closeSync,fstatSync,readSync,readFileSync,constants} from 'node:fs';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {randomBytes} from 'node:crypto';
|
|
6
|
+
import {canonical,digest} from './approval-ledger.js';
|
|
7
|
+
import {validateProcessReport} from './delivery-process.js';
|
|
8
|
+
const fail=c=>{throw Error('DELIVERY_RECEIPT_'+c);};
|
|
9
|
+
const same=(a,b)=>canonical(a)===canonical(b);
|
|
10
|
+
const stamp=s=>[s.dev,s.ino,s.mode,s.uid,s.nlink,s.size,s.mtimeNs,s.ctimeNs].map(String).join(':');
|
|
11
|
+
const sha=x=>typeof x==='string'&&/^[a-f0-9]{64}$/.test(x);
|
|
12
|
+
const uuid=x=>typeof x==='string'&&/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(x);
|
|
13
|
+
export function bootId(){const id=readFileSync('/proc/sys/kernel/random/boot_id','ascii').trim();if(!uuid(id))fail('BOOT_UNKNOWN');return id;}
|
|
14
|
+
export function supervisorLive(pid,start,boot){
|
|
15
|
+
if(bootId()!==boot)return false;
|
|
16
|
+
let raw;try{raw=readFileSync('/proc/'+pid+'/stat','ascii');}catch(e){if(e.code==='ENOENT')return false;throw e;}
|
|
17
|
+
const rest=raw.slice(raw.lastIndexOf(')')+2).split(' ');
|
|
18
|
+
if(rest.length<20||!/^\d+$/.test(rest[19])||!rest[0])fail('SUPERVISOR_UNKNOWN');
|
|
19
|
+
return rest[19]===start&&!['Z','X'].includes(rest[0]);
|
|
20
|
+
}
|
|
21
|
+
export function validateReceiptBinding(v){
|
|
22
|
+
if(!v||Object.keys(v).sort().join(',')!=='action_id,binding_hash,boot_id,directory,epoch,intent_fingerprint,nonce,operation_id,plan_fingerprint,schema'||v.schema!==1||
|
|
23
|
+
!/^delivery-op-[a-f0-9-]{36}$/.test(v.operation_id??'')||!/^delivery-[a-f0-9-]{36}$/.test(v.action_id??'')||
|
|
24
|
+
!Number.isSafeInteger(v.epoch)||v.epoch<1||![v.binding_hash,v.nonce,v.intent_fingerprint,v.plan_fingerprint].every(sha)||!uuid(v.boot_id)||
|
|
25
|
+
!v.directory||Object.keys(v.directory).sort().join(',')!=='device,inode,path'||typeof v.directory.path!=='string'||!v.directory.path.startsWith('/')||
|
|
26
|
+
v.directory.path.length>2048||/[\x00-\x1f\x7f]/.test(v.directory.path)||!/^\d{1,30}$/.test(v.directory.device)||!/^\d{1,30}$/.test(v.directory.inode))fail('BINDING_INVALID');
|
|
27
|
+
const {binding_hash,...body}=v;if(digest(body)!==binding_hash)fail('BINDING_INVALID');return v;
|
|
28
|
+
}
|
|
29
|
+
export class DeliveryProcessReceipts {
|
|
30
|
+
constructor({journal,directory,enabled=false},io={}){
|
|
31
|
+
this.j=journal;this.db=journal.db;this.directory=directory;this.enabled=enabled;this.boot=io.bootId??bootId;this.live=io.supervisorLive??supervisorLive;
|
|
32
|
+
if(enabled!==true)fail('DISABLED');
|
|
33
|
+
if(typeof directory!=='string'||!directory.startsWith('/'))fail('DIRECTORY_INVALID');
|
|
34
|
+
try{mkdirSync(directory,{mode:0o700});}catch(e){if(e.code!=='EEXIST')throw e;}this.directoryIdentity=this.checkDirectory();
|
|
35
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS delivery_process_receipts(operation_id TEXT PRIMARY KEY,nonce TEXT UNIQUE NOT NULL,
|
|
36
|
+
binding TEXT NOT NULL,created INTEGER NOT NULL);`);
|
|
37
|
+
}
|
|
38
|
+
checkDirectory(expected){
|
|
39
|
+
const s=lstatSync(this.directory,{bigint:true});
|
|
40
|
+
if(!s.isDirectory()||s.isSymbolicLink()||realpathSync(this.directory)!==this.directory||(s.mode&0o077n)!==0n||s.uid!==BigInt(process.getuid()))fail('DIRECTORY_CHANGED');
|
|
41
|
+
const v={path:this.directory,device:String(s.dev),inode:String(s.ino)};if(expected&&!same(v,expected))fail('DIRECTORY_CHANGED');return v;
|
|
42
|
+
}
|
|
43
|
+
prepare(operation_id){
|
|
44
|
+
if(this.enabled!==true)fail('DISABLED');
|
|
45
|
+
const o=this.j.operation(operation_id),i=this.j.effectAuthority(o.action_id,o.epoch);
|
|
46
|
+
if(o.kind!=='push'||o.state!=='intent'||o.pid!==null||i.kind!=='push-execution-intent-v1')fail('OPERATION_INVALID');
|
|
47
|
+
this.checkDirectory(this.directoryIdentity);
|
|
48
|
+
const body={schema:1,operation_id,action_id:o.action_id,epoch:o.epoch,intent_fingerprint:i.fingerprint,plan_fingerprint:i.plan.fingerprint,
|
|
49
|
+
nonce:randomBytes(32).toString('hex'),boot_id:this.boot(),directory:this.directoryIdentity};
|
|
50
|
+
const b=validateReceiptBinding({...body,binding_hash:digest(body)});
|
|
51
|
+
this.db.prepare('INSERT INTO delivery_process_receipts VALUES (?,?,?,?)').run(operation_id,b.nonce,canonical(b),this.j.now());return b;
|
|
52
|
+
}
|
|
53
|
+
binding(operation_id){
|
|
54
|
+
if(this.enabled!==true)fail('DISABLED');
|
|
55
|
+
const row=this.db.prepare('SELECT * FROM delivery_process_receipts WHERE operation_id=?').get(operation_id);if(!row)fail('BINDING_REQUIRED');
|
|
56
|
+
const b=validateReceiptBinding(JSON.parse(row.binding)),o=this.j.operation(operation_id),i=this.j.intent(this.j.row(o.action_id));
|
|
57
|
+
if(b.operation_id!==operation_id||row.nonce!==b.nonce||b.action_id!==o.action_id||b.epoch!==o.epoch||o.kind!=='push'||
|
|
58
|
+
b.intent_fingerprint!==i.fingerprint||b.plan_fingerprint!==i.plan.fingerprint||!same(b.directory,this.directoryIdentity))fail('OPERATION_CHANGED');
|
|
59
|
+
this.checkDirectory(b.directory);return {b,o};
|
|
60
|
+
}
|
|
61
|
+
inspect(operation_id){
|
|
62
|
+
const {b,o}=this.binding(operation_id),path=join(this.directory,b.nonce+'.json'),before=lstatSync(path,{bigint:true});
|
|
63
|
+
if(!before.isFile()||before.isSymbolicLink()||before.nlink!==1n||(before.mode&0o077n)!==0n||before.uid!==BigInt(process.getuid())||before.size<1n||before.size>16384n)fail('FILE_INVALID');
|
|
64
|
+
const fd=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);let data;
|
|
65
|
+
try{
|
|
66
|
+
if(stamp(fstatSync(fd,{bigint:true}))!==stamp(before))fail('FILE_CHANGED');
|
|
67
|
+
const bytes=Buffer.alloc(Number(before.size)+1);let n=0,k;while(n<bytes.length&&(k=readSync(fd,bytes,n,bytes.length-n,null))>0)n+=k;
|
|
68
|
+
if(n!==Number(before.size)||stamp(fstatSync(fd,{bigint:true}))!==stamp(before)||stamp(lstatSync(path,{bigint:true}))!==stamp(before))fail('FILE_CHANGED');data=bytes.subarray(0,n);
|
|
69
|
+
}finally{closeSync(fd);}
|
|
70
|
+
this.checkDirectory(b.directory);let r;try{r=JSON.parse(data.toString('utf8'));}catch{fail('FILE_INVALID');}
|
|
71
|
+
if(!r||Object.keys(r).sort().join(',')!=='binding_hash,boot_id,kind,operation_id,pid,report,schema,start_ticks'||r.schema!==1||r.kind!=='sealed-process-receipt-v1'||
|
|
72
|
+
r.binding_hash!==b.binding_hash||r.boot_id!==b.boot_id||r.operation_id!==operation_id||r.pid!==o.pid||!Number.isSafeInteger(r.pid)||r.pid<1||
|
|
73
|
+
typeof r.start_ticks!=='string'||!/^\d{1,30}$/.test(r.start_ticks)||!r.report||Object.keys(r.report).sort().join(',')!=='cause,code,drained,nonce,reaped,schema,signal,started')fail('REPORT_INVALID');
|
|
74
|
+
// Receipt has no stdout/stderr, argv, environment or credential paths.
|
|
75
|
+
const report=validateProcessReport(JSON.stringify({...r.report,stdout:''}),b.nonce,0,null);
|
|
76
|
+
const live=this.live(r.pid,r.start_ticks,r.boot_id);if(typeof live!=='boolean')fail('SUPERVISOR_UNKNOWN');
|
|
77
|
+
const proof={operation_id,action_id:b.action_id,epoch:b.epoch,binding_hash:b.binding_hash,receipt_sha256:digest(data.toString('utf8')),
|
|
78
|
+
state:live?'supervisor_live':'terminal_verified',code:report.code,signal:report.signal,cause:report.cause,started:report.started,reaped:report.reaped,grants_execution:false};
|
|
79
|
+
return {...proof,fingerprint:digest({...proof,file_stamp:stamp(before)})};
|
|
80
|
+
}
|
|
81
|
+
reconcile(operation_id,expectedFingerprint){
|
|
82
|
+
if(!sha(expectedFingerprint))fail('EVIDENCE_REQUIRED');
|
|
83
|
+
const {o}=this.binding(operation_id),row=this.j.fence(o.action_id,o.epoch,['needs_review']);
|
|
84
|
+
if(this.j.store.task(row.task_id).revision!==this.j.intent(row).task_revision)fail('TASK_REVISION_CHANGED');
|
|
85
|
+
const proof=this.inspect(operation_id);
|
|
86
|
+
if(proof.state!=='terminal_verified'||proof.fingerprint!==expectedFingerprint)fail('EVIDENCE_CHANGED');
|
|
87
|
+
this.j.closed(operation_id,{code:proof.code,signal:proof.signal,evidence:{kind:'sealed-process-receipt-v1',binding_hash:proof.binding_hash,receipt_sha256:proof.receipt_sha256}});
|
|
88
|
+
return {operation_id,state:'closed',task_released:false,retry_authorized:false};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Private Linux supervisor client. Direct-child close alone does not close the
|
|
2
|
+
// durable operation: require the nonce-bound ECHILD + pipe-drain report as well.
|
|
3
|
+
import {spawn} from 'node:child_process';
|
|
4
|
+
import {fileURLToPath} from 'node:url';
|
|
5
|
+
import {randomBytes} from 'node:crypto';
|
|
6
|
+
const fail=c=>Object.assign(Error('DELIVERY_PROCESS_'+c),{drained:false});
|
|
7
|
+
const causes=new Set([null,'ABORTED','SPAWN_FAILED','ORPHANED_DESCENDANT','TIMEOUT','OUTPUT_LIMIT']);
|
|
8
|
+
export function validateProcessReport(raw,nonce,exitCode,signal){
|
|
9
|
+
let r;try{r=JSON.parse(raw);}catch{throw fail('REPORT_INVALID');}
|
|
10
|
+
if(!r||Object.keys(r).sort().join(',')!=='cause,code,drained,nonce,reaped,schema,signal,started,stdout'||exitCode!==0||signal!==null||r.schema!==1||r.nonce!==nonce||r.drained!==true||typeof r.started!=='boolean'||
|
|
11
|
+
!Number.isSafeInteger(r.reaped)||r.reaped<(r.started?1:0)||!causes.has(r.cause)||
|
|
12
|
+
!(r.code===null||Number.isSafeInteger(r.code)&&r.code>=0&&r.code<=255)||
|
|
13
|
+
!(r.signal===null||typeof r.signal==='string'&&/^SIG[A-Z0-9]{1,24}$/.test(r.signal))||
|
|
14
|
+
(r.code===null&&r.signal===null&&r.started)||r.code!==null&&r.signal!==null||
|
|
15
|
+
typeof r.stdout!=='string'||r.stdout.length>1398104||Buffer.from(r.stdout,'base64').toString('base64')!==r.stdout)throw fail('DRAIN_UNKNOWN');
|
|
16
|
+
if(!r.started&&(r.code!==null||r.signal!==null||r.reaped!==0||r.stdout!==''||!['ABORTED','SPAWN_FAILED'].includes(r.cause)))throw fail('REPORT_INVALID');
|
|
17
|
+
return {...r,stdout:Buffer.from(r.stdout,'base64')};
|
|
18
|
+
}
|
|
19
|
+
export function runSupervised(argv,{cwd,env,timeout=20,limit=1048576,signal,onSpawn,onDrained,receipt}={},io={}){
|
|
20
|
+
if((io.platform??process.platform)!=='linux')return Promise.reject(fail('PLATFORM_UNSUPPORTED'));
|
|
21
|
+
if(signal?.aborted)return Promise.reject(Object.assign(fail('ABORTED'),{drained:true}));
|
|
22
|
+
const nonce=receipt?.nonce??randomBytes(32).toString('hex'),request=JSON.stringify({argv,cwd,env,timeout,limit,nonce,parent_pid:process.pid,...(receipt?{receipt}: {})});
|
|
23
|
+
if(request.length>131072)return Promise.reject(fail('INPUT_LIMIT'));
|
|
24
|
+
return new Promise((resolve,reject)=>{
|
|
25
|
+
let child,output='',invalid=false;
|
|
26
|
+
const abort=()=>{try{child.kill('SIGTERM');}catch{invalid=true;}};
|
|
27
|
+
try{
|
|
28
|
+
child=(io.spawn??spawn)('/usr/bin/python3',['-I','-B',fileURLToPath(new URL('./delivery-process.py',import.meta.url))],{
|
|
29
|
+
cwd,env:{PATH:'/usr/bin:/bin',LC_ALL:'C'},stdio:['pipe','pipe','pipe']});
|
|
30
|
+
child.stdout.on('data',b=>{if(output.length+b.length>1500000){invalid=true;abort();}else output+=b.toString('utf8');});
|
|
31
|
+
child.stderr.on('data',()=>{}); // Never log inherited diagnostics/credentials.
|
|
32
|
+
child.on('error',()=>{invalid=true;});child.stdin.on('error',()=>{invalid=true;});
|
|
33
|
+
child.once('close',(code,closedSignal)=>{
|
|
34
|
+
signal?.removeEventListener('abort',abort);
|
|
35
|
+
try{if(invalid)throw fail('DRAIN_UNKNOWN');const r=validateProcessReport(output,nonce,code,closedSignal);
|
|
36
|
+
onDrained?.({code:r.code,signal:r.signal,started:r.started,cause:r.cause,reaped:r.reaped});resolve(r);
|
|
37
|
+
}catch{reject(fail('DRAIN_UNKNOWN'));}
|
|
38
|
+
});
|
|
39
|
+
if(Number.isSafeInteger(child.pid))onSpawn?.({pid:child.pid});
|
|
40
|
+
signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort();
|
|
41
|
+
child.stdin.end(request);
|
|
42
|
+
}catch{
|
|
43
|
+
if(child){invalid=true;abort();}else reject(fail('START_FAILED'));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Private Linux child-tree supervisor. No shell, retries, or PID-disappearance proof.
|
|
2
|
+
|
|
3
|
+
The parent journals the supervisor before use. Only this supervisor's ECHILD after
|
|
4
|
+
waitpid reaping, plus closed output pipes, proves its managed descendants drained.
|
|
5
|
+
An absent/invalid final report never proves drainage, even if this process exited.
|
|
6
|
+
"""
|
|
7
|
+
import base64
|
|
8
|
+
import ctypes
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import selectors
|
|
14
|
+
import signal
|
|
15
|
+
import stat
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def supervise(argv, cwd, env, timeout=20.0, limit=1048576, expected_parent=None):
|
|
22
|
+
if sys.platform != 'linux':
|
|
23
|
+
raise RuntimeError('PLATFORM_UNSUPPORTED')
|
|
24
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
25
|
+
if libc.prctl(36, 1, 0, 0, 0) != 0: # PR_SET_CHILD_SUBREAPER
|
|
26
|
+
raise RuntimeError('SUBREAPER_UNAVAILABLE')
|
|
27
|
+
enabled = ctypes.c_int()
|
|
28
|
+
if libc.prctl(37, ctypes.byref(enabled), 0, 0, 0) != 0 or enabled.value != 1:
|
|
29
|
+
raise RuntimeError('SUBREAPER_UNAVAILABLE')
|
|
30
|
+
stopping = [False]
|
|
31
|
+
signal.signal(signal.SIGTERM, lambda *_: stopping.__setitem__(0, True))
|
|
32
|
+
signal.signal(signal.SIGINT, lambda *_: stopping.__setitem__(0, True))
|
|
33
|
+
parent = os.getppid()
|
|
34
|
+
if libc.prctl(1, signal.SIGTERM, 0, 0, 0) != 0: # PR_SET_PDEATHSIG
|
|
35
|
+
raise RuntimeError('PARENT_SIGNAL_UNAVAILABLE')
|
|
36
|
+
if os.getppid() != parent or expected_parent is not None and parent != expected_parent:
|
|
37
|
+
stopping[0] = True
|
|
38
|
+
if stopping[0]:
|
|
39
|
+
return dict(drained=True, started=False, code=None, signal=None, cause='ABORTED', stdout='', reaped=0)
|
|
40
|
+
child = None
|
|
41
|
+
pipes = selectors.DefaultSelector()
|
|
42
|
+
out, total, reaped, code, main_done = bytearray(), 0, 0, None, False
|
|
43
|
+
cause, kill_since = None, None
|
|
44
|
+
start = time.monotonic()
|
|
45
|
+
try:
|
|
46
|
+
try:
|
|
47
|
+
child = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.DEVNULL,
|
|
48
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
49
|
+
shell=False, start_new_session=True, close_fds=True)
|
|
50
|
+
except OSError:
|
|
51
|
+
return dict(drained=True, started=False, code=None, signal=None, cause='SPAWN_FAILED', stdout='', reaped=0)
|
|
52
|
+
for stream, label in [(child.stdout, 'out'), (child.stderr, 'err')]:
|
|
53
|
+
os.set_blocking(stream.fileno(), False)
|
|
54
|
+
pipes.register(stream, selectors.EVENT_READ, label)
|
|
55
|
+
while True:
|
|
56
|
+
empty = False
|
|
57
|
+
while True:
|
|
58
|
+
try:
|
|
59
|
+
pid, status = os.waitpid(-1, os.WNOHANG)
|
|
60
|
+
except ChildProcessError: # ECHILD, not kill(pid, 0) or /proc absence.
|
|
61
|
+
empty = True
|
|
62
|
+
break
|
|
63
|
+
if pid == 0:
|
|
64
|
+
break
|
|
65
|
+
reaped += 1
|
|
66
|
+
if pid == child.pid:
|
|
67
|
+
code = os.waitstatus_to_exitcode(status)
|
|
68
|
+
child.returncode = code
|
|
69
|
+
main_done = True
|
|
70
|
+
else:
|
|
71
|
+
cause = cause or 'ORPHANED_DESCENDANT'
|
|
72
|
+
now = time.monotonic()
|
|
73
|
+
if stopping[0]:
|
|
74
|
+
cause = cause or 'ABORTED'
|
|
75
|
+
if now - start >= timeout:
|
|
76
|
+
cause = cause or 'TIMEOUT'
|
|
77
|
+
if main_done and not empty:
|
|
78
|
+
cause = cause or 'ORPHANED_DESCENDANT'
|
|
79
|
+
if cause and not empty:
|
|
80
|
+
kill_since = kill_since or now
|
|
81
|
+
# Only our unreaped direct/adopted children. Do not kill a cached
|
|
82
|
+
# process-group ID that could be reused after its leader was reaped.
|
|
83
|
+
try:
|
|
84
|
+
with open(f'/proc/self/task/{os.getpid()}/children', encoding='ascii') as f:
|
|
85
|
+
children = [int(x) for x in f.read().split()]
|
|
86
|
+
for pid in children:
|
|
87
|
+
try:
|
|
88
|
+
os.kill(pid, signal.SIGKILL)
|
|
89
|
+
except ProcessLookupError:
|
|
90
|
+
pass
|
|
91
|
+
except OSError:
|
|
92
|
+
cause = 'DRAIN_UNKNOWN'
|
|
93
|
+
break
|
|
94
|
+
if now - kill_since > 5:
|
|
95
|
+
cause = 'DRAIN_UNKNOWN'
|
|
96
|
+
break
|
|
97
|
+
for key, _ in pipes.select(0.02):
|
|
98
|
+
try:
|
|
99
|
+
data = os.read(key.fileobj.fileno(), 65536)
|
|
100
|
+
except BlockingIOError:
|
|
101
|
+
continue
|
|
102
|
+
if not data:
|
|
103
|
+
pipes.unregister(key.fileobj)
|
|
104
|
+
key.fileobj.close()
|
|
105
|
+
continue
|
|
106
|
+
total += len(data)
|
|
107
|
+
if total > limit:
|
|
108
|
+
cause = cause or 'OUTPUT_LIMIT'
|
|
109
|
+
elif key.data == 'out':
|
|
110
|
+
out.extend(data)
|
|
111
|
+
if empty and main_done and not pipes.get_map():
|
|
112
|
+
return dict(drained=True, started=True, code=code if code >= 0 else None,
|
|
113
|
+
signal=signal.Signals(-code).name if code < 0 else None,
|
|
114
|
+
cause=cause, stdout=base64.b64encode(out).decode('ascii'), reaped=reaped)
|
|
115
|
+
if now - start > timeout + 6:
|
|
116
|
+
cause = 'DRAIN_UNKNOWN'
|
|
117
|
+
break
|
|
118
|
+
return dict(drained=False, started=True, code=None, signal=None, cause='DRAIN_UNKNOWN', stdout='', reaped=reaped)
|
|
119
|
+
finally:
|
|
120
|
+
for key in list(pipes.get_map().values()):
|
|
121
|
+
key.fileobj.close()
|
|
122
|
+
pipes.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def receipt_directory(binding, nonce):
|
|
126
|
+
keys = {'schema', 'operation_id', 'action_id', 'epoch', 'intent_fingerprint',
|
|
127
|
+
'plan_fingerprint', 'nonce', 'boot_id', 'directory', 'binding_hash'}
|
|
128
|
+
if not isinstance(binding, dict) or set(binding) != keys:
|
|
129
|
+
raise ValueError()
|
|
130
|
+
body = {k: v for k, v in binding.items() if k != 'binding_hash'}
|
|
131
|
+
encoded = json.dumps(body, sort_keys=True, ensure_ascii=False, separators=(',', ':')).encode()
|
|
132
|
+
directory = binding['directory']
|
|
133
|
+
if (binding['schema'] != 1 or binding['nonce'] != nonce or
|
|
134
|
+
not re.fullmatch(r'delivery-op-[a-f0-9-]{36}', binding['operation_id']) or
|
|
135
|
+
not re.fullmatch(r'delivery-[a-f0-9-]{36}', binding['action_id']) or
|
|
136
|
+
type(binding['epoch']) is not int or not 1 <= binding['epoch'] <= 9007199254740991 or
|
|
137
|
+
any(not isinstance(binding[k], str) or not re.fullmatch(r'[a-f0-9]{64}', binding[k])
|
|
138
|
+
for k in ['intent_fingerprint', 'plan_fingerprint', 'nonce', 'binding_hash']) or
|
|
139
|
+
hashlib.sha256(encoded).hexdigest() != binding['binding_hash'] or
|
|
140
|
+
not isinstance(directory, dict) or set(directory) != {'path', 'device', 'inode'} or
|
|
141
|
+
not isinstance(directory['path'], str) or not directory['path'].startswith('/') or
|
|
142
|
+
len(directory['path']) > 2048 or os.path.realpath(directory['path']) != directory['path']):
|
|
143
|
+
raise ValueError()
|
|
144
|
+
with open('/proc/sys/kernel/random/boot_id', encoding='ascii') as source:
|
|
145
|
+
if source.read().strip() != binding['boot_id']:
|
|
146
|
+
raise ValueError()
|
|
147
|
+
fd = os.open(directory['path'], os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
148
|
+
try:
|
|
149
|
+
current = os.fstat(fd)
|
|
150
|
+
if (not stat.S_ISDIR(current.st_mode) or current.st_mode & 0o077 or
|
|
151
|
+
current.st_uid != os.geteuid() or str(current.st_dev) != directory['device'] or
|
|
152
|
+
str(current.st_ino) != directory['inode']):
|
|
153
|
+
raise ValueError()
|
|
154
|
+
return fd
|
|
155
|
+
except Exception:
|
|
156
|
+
os.close(fd)
|
|
157
|
+
raise
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def seal_receipt(fd, binding, result):
|
|
161
|
+
# Called only after supervise returned ECHILD + closed descendant pipes.
|
|
162
|
+
# No child is ever started after this point. Publish once, without replacing
|
|
163
|
+
# any existing report; fsync bytes and directory before stdout acknowledgement.
|
|
164
|
+
if result['drained'] is not True:
|
|
165
|
+
return
|
|
166
|
+
with open('/proc/self/stat', encoding='ascii') as source:
|
|
167
|
+
start_ticks = source.read().rsplit(')', 1)[1].strip().split()[19]
|
|
168
|
+
report = {k: v for k, v in result.items() if k != 'stdout'}
|
|
169
|
+
data = json.dumps({'schema': 1, 'kind': 'sealed-process-receipt-v1',
|
|
170
|
+
'operation_id': binding['operation_id'], 'binding_hash': binding['binding_hash'],
|
|
171
|
+
'boot_id': binding['boot_id'], 'pid': os.getpid(), 'start_ticks': start_ticks,
|
|
172
|
+
'report': {'schema': 1, 'nonce': binding['nonce'], **report}},
|
|
173
|
+
sort_keys=True, separators=(',', ':')).encode()
|
|
174
|
+
if len(data) > 16384:
|
|
175
|
+
raise ValueError()
|
|
176
|
+
temporary, final = '.'+binding['nonce']+'.tmp', binding['nonce']+'.json'
|
|
177
|
+
output = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=fd)
|
|
178
|
+
try:
|
|
179
|
+
with os.fdopen(output, 'wb') as stream:
|
|
180
|
+
stream.write(data)
|
|
181
|
+
stream.flush()
|
|
182
|
+
os.fsync(stream.fileno())
|
|
183
|
+
os.link(temporary, final, src_dir_fd=fd, dst_dir_fd=fd, follow_symlinks=False)
|
|
184
|
+
os.unlink(temporary, dir_fd=fd)
|
|
185
|
+
os.fsync(fd)
|
|
186
|
+
except Exception:
|
|
187
|
+
# Retain an incomplete private file for diagnosis, never overwrite/adopt it.
|
|
188
|
+
raise
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main():
|
|
192
|
+
# Private IPC only. The public plugin accepts opaque plans, never argv/env.
|
|
193
|
+
receipt_fd = None
|
|
194
|
+
try:
|
|
195
|
+
raw = sys.stdin.buffer.read(131073)
|
|
196
|
+
if len(raw) > 131072:
|
|
197
|
+
raise ValueError()
|
|
198
|
+
request = json.loads(raw)
|
|
199
|
+
keys = {'argv', 'cwd', 'env', 'timeout', 'limit', 'nonce', 'parent_pid'}
|
|
200
|
+
if set(request) not in (keys, keys | {'receipt'}):
|
|
201
|
+
raise ValueError()
|
|
202
|
+
argv, env = request['argv'], request['env']
|
|
203
|
+
if (not isinstance(argv, list) or not 1 <= len(argv) <= 100 or
|
|
204
|
+
any(not isinstance(x, str) or '\0' in x or len(x) > 8192 for x in argv) or
|
|
205
|
+
not argv[0].startswith('/') or not isinstance(env, dict) or len(env) > 100 or
|
|
206
|
+
any(not isinstance(k, str) or not isinstance(v, str) or '=' in k or '\0' in k+v for k, v in env.items()) or
|
|
207
|
+
not isinstance(request['cwd'], str) or not request['cwd'].startswith('/') or
|
|
208
|
+
not 0.05 <= request['timeout'] <= 60 or not 1 <= request['limit'] <= 1048576 or
|
|
209
|
+
not isinstance(request['parent_pid'], int) or request['parent_pid'] < 1 or
|
|
210
|
+
not isinstance(request['nonce'], str) or not re.fullmatch(r'[a-f0-9]{64}', request['nonce'])):
|
|
211
|
+
raise ValueError()
|
|
212
|
+
if 'receipt' in request:
|
|
213
|
+
receipt_fd = receipt_directory(request['receipt'], request['nonce'])
|
|
214
|
+
result = supervise(argv, request['cwd'], env, request['timeout'], request['limit'], request['parent_pid'])
|
|
215
|
+
if receipt_fd is not None:
|
|
216
|
+
seal_receipt(receipt_fd, request['receipt'], result)
|
|
217
|
+
print(json.dumps({'schema': 1, 'nonce': request['nonce'], **result}), flush=True)
|
|
218
|
+
except Exception:
|
|
219
|
+
# Never expose argv, keys, environment, Git stderr or exception strings.
|
|
220
|
+
print(json.dumps({'schema': 1, 'drained': False, 'cause': 'SUPERVISOR_FAILED'}), flush=True)
|
|
221
|
+
raise SystemExit(1)
|
|
222
|
+
finally:
|
|
223
|
+
if receipt_fd is not None:
|
|
224
|
+
os.close(receipt_fd)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == '__main__':
|
|
228
|
+
main()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Separate push authority over a completed local commit. No Git/network effects.
|
|
2
|
+
// Reuses the same ledger and trusted decision verifier; a commit approval never
|
|
3
|
+
// authorizes push, and an old remote observation is not an execution preflight.
|
|
4
|
+
import {randomUUID} from 'node:crypto';
|
|
5
|
+
import {canonical,digest} from './approval-ledger.js';
|
|
6
|
+
const fail=c=>{throw Error('DELIVERY_PUSH_APPROVAL_'+c);};
|
|
7
|
+
const same=(a,b)=>canonical(a)===canonical(b);
|
|
8
|
+
const proofHash=r=>digest({task_id:r.task_id,spec_revision:r.spec_revision,task_hash:r.task_hash,proposal:JSON.parse(r.proposal),repository_revision:r.repository_revision,expires:r.expires});
|
|
9
|
+
export class DeliveryPushApproval {
|
|
10
|
+
constructor({commit,journal,enabled=false}){
|
|
11
|
+
this.commit=commit;this.j=journal;this.l=commit.l;this.db=this.l.db;this.enabled=enabled;
|
|
12
|
+
if(journal.db!==this.db)fail('DATABASE_MISMATCH');
|
|
13
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS delivery_push_plans(id TEXT PRIMARY KEY,event_id TEXT UNIQUE NOT NULL,
|
|
14
|
+
task_id TEXT NOT NULL,event_fingerprint TEXT NOT NULL,snapshot TEXT NOT NULL,created INTEGER NOT NULL);`);
|
|
15
|
+
}
|
|
16
|
+
source(task,action){
|
|
17
|
+
const r=this.j.row(action),i=this.j.intent(r),a=this.l.row(r.approval_id),p=JSON.parse(a.proposal),d=p.delivery;
|
|
18
|
+
if(r.task_id!==task||r.state!=='completed'||i.kind!=='commit-execution-intent-v1'||a.task_id!==task||
|
|
19
|
+
!same(p.scopes,['commit'])||d?.kind!=='commit-execution-v1'||proofHash(a)!==a.proposal_hash)fail('SOURCE_REQUIRED');
|
|
20
|
+
const s=this.commit.stored(task,d.plan_id,d.fingerprint);this.commit.current(s,a.spec_revision);
|
|
21
|
+
if(!same(this.commit.snapshot(s),d)||!same(s.intent,i)||!same(s.policy,p.model_policy)||!same(s.repository,p.repository)||
|
|
22
|
+
!this.l.repositories?.matches(task,a.repository_revision,p.repository)||p.repository?.provider!=='gitee'||
|
|
23
|
+
!Number.isSafeInteger(p.repository.repository_id)||p.repository.repository_id<1)fail('SOURCE_CHANGED');
|
|
24
|
+
return {row:r,intent:i,result:JSON.parse(r.result),repository:p.repository,policy:p.model_policy,approval_hash:a.proposal_hash};
|
|
25
|
+
}
|
|
26
|
+
stage({event_id,task_id,resource,workspace_identity,plan}){
|
|
27
|
+
if(this.enabled!==true)fail('DISABLED');
|
|
28
|
+
if(typeof event_id!=='string'||!event_id.trim()||event_id.length>256||/[\x00-\x1f\x7f]/.test(event_id))fail('INVALID_EVENT');
|
|
29
|
+
return this.l.store.tx(()=>{
|
|
30
|
+
const intent=this.j.prepare({task_id,resource,workspace_identity,plan}),event_fingerprint=digest(intent);
|
|
31
|
+
const old=this.db.prepare('SELECT * FROM delivery_push_plans WHERE event_id=?').get(event_id);
|
|
32
|
+
if(old){if(old.event_fingerprint!==event_fingerprint)fail('EVENT_CONFLICT');const s=this.stored(task_id,old.id,JSON.parse(old.snapshot).fingerprint);return {reference:this.reference(s),replayed:true,grants_execution:false};}
|
|
33
|
+
const source=this.source(task_id,plan.source_action_id),h=this.l.catalog.head(task_id);
|
|
34
|
+
if(h.legacy||this.l.store.task(task_id).blocked||source.repository.url!==resource||source.repository.repository_id!==plan.repository_id)fail('SOURCE_CHANGED');
|
|
35
|
+
this.j.readiness(intent); // A private, fresh, read-only preflight was primed before this transaction.
|
|
36
|
+
const body={schema:1,kind:'push-approval-plan-v1',id:'push-plan-'+randomUUID(),task_id,spec_revision:h.spec_revision,task_hash:digest(h.goal),
|
|
37
|
+
repository:structuredClone(source.repository),policy:structuredClone(source.policy),source_approval_hash:source.approval_hash,intent,created:this.l.now()};
|
|
38
|
+
const s={...body,fingerprint:digest(body)};
|
|
39
|
+
this.db.prepare('INSERT INTO delivery_push_plans VALUES (?,?,?,?,?,?)').run(s.id,event_id,task_id,event_fingerprint,canonical(s),s.created);
|
|
40
|
+
return {reference:this.reference(s),replayed:false,grants_execution:false};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
reference(s){return {kind:'push-execution-reference-v1',plan_id:s.id,fingerprint:s.fingerprint};}
|
|
44
|
+
stored(task,id,fingerprint){
|
|
45
|
+
const row=this.db.prepare('SELECT * FROM delivery_push_plans WHERE id=?').get(id);if(!row||row.task_id!==task)fail('PLAN_NOT_FOUND');
|
|
46
|
+
const s=JSON.parse(row.snapshot),{fingerprint:hash,...body}=s;
|
|
47
|
+
if(s.id!==id||s.task_id!==task||s.kind!=='push-approval-plan-v1'||s.schema!==1||hash!==fingerprint||digest(body)!==hash)fail('PLAN_CHANGED');
|
|
48
|
+
if(!same(this.j.prepare(s.intent),s.intent))fail('SOURCE_CHANGED');return s;
|
|
49
|
+
}
|
|
50
|
+
current(s,revision){
|
|
51
|
+
const h=this.l.catalog.head(s.task_id),source=this.source(s.task_id,s.intent.plan.source_action_id);
|
|
52
|
+
if(h.spec_revision!==revision||s.spec_revision!==revision||digest(h.goal)!==s.task_hash||
|
|
53
|
+
!same(source.repository,s.repository)||!same(source.policy,s.policy)||source.approval_hash!==s.source_approval_hash)fail('SOURCE_CHANGED');
|
|
54
|
+
}
|
|
55
|
+
snapshot(s){
|
|
56
|
+
const p=s.intent.plan;
|
|
57
|
+
return {kind:'push-execution-v1',plan_id:s.id,fingerprint:s.fingerprint,intent_fingerprint:s.intent.fingerprint,plan_fingerprint:p.fingerprint,
|
|
58
|
+
source_action_id:p.source_action_id,source_approval_hash:s.source_approval_hash,task_revision:s.spec_revision,workspace_revision:s.intent.task_revision,
|
|
59
|
+
repository_id:p.repository_id,repository_url:p.url,repository_fingerprint:s.repository.fingerprint,workspace_branch:p.branch,ref:p.ref,
|
|
60
|
+
expected_remote_oid:p.expected_remote_oid,commit_oid:p.commit_oid,tree_oid:p.tree_oid,manifest_sha256:p.manifest_sha256,
|
|
61
|
+
tested_content:structuredClone(p.tested_content),prepared_at:s.created,execution:'requires_runtime_gate',max_pushes:1,
|
|
62
|
+
hooks:false,signed:false,rewrite:false,delete:false,tags:false,commit:false,publish:false,deploy:false};
|
|
63
|
+
}
|
|
64
|
+
approvalSnapshot(task,revision,reference,repository,policy){
|
|
65
|
+
if(reference.kind!=='push-execution-reference-v1')return this.commit.approvalSnapshot(task,revision,reference,repository,policy);
|
|
66
|
+
if(this.enabled!==true)fail('DISABLED');const s=this.stored(task,reference.plan_id,reference.fingerprint);this.current(s,revision);
|
|
67
|
+
if(!same(s.repository,repository)||!same(s.policy,policy))fail('BINDING_CHANGED');
|
|
68
|
+
// Human approval can arrive later. No network I/O or freshness renewal here;
|
|
69
|
+
// acquisition and transmission must obtain a new preflight for this exact baseline.
|
|
70
|
+
return this.snapshot(s);
|
|
71
|
+
}
|
|
72
|
+
matches(task,revision,plan){
|
|
73
|
+
if(plan?.kind!=='push-execution-v1')return this.commit.matches(task,revision,plan);
|
|
74
|
+
try{if(this.enabled!==true)return false;const s=this.stored(task,plan.plan_id,plan.fingerprint);this.current(s,revision);return same(this.snapshot(s),plan);}catch{return false;}
|
|
75
|
+
}
|
|
76
|
+
authorize(a){
|
|
77
|
+
if(a.intent?.kind!=='push-execution-intent-v1')return this.commit.authorize(a);
|
|
78
|
+
if(this.enabled!==true)fail('DISABLED');
|
|
79
|
+
return this.commit.readSnapshot(()=>{
|
|
80
|
+
const r=this.l.row(a.approval_id),v=this.l.view(r.id),p=v.proposal.delivery;
|
|
81
|
+
if(v.state!=='approved'||!same(v.proposal.scopes,['push'])||p?.kind!=='push-execution-v1'||proofHash(r)!==r.proposal_hash)fail('EXECUTION_APPROVAL_REQUIRED');
|
|
82
|
+
const s=this.stored(r.task_id,p.plan_id,p.fingerprint);
|
|
83
|
+
if(!same(s.intent,a.intent)||!same(s.policy,v.proposal.model_policy)||!same(s.repository,v.proposal.repository))fail('INTENT_CHANGED');
|
|
84
|
+
const proof=this.commit.decisionProof(r);
|
|
85
|
+
return {kind:'delivery-execution-v1',scope:'push',execution:true,approval_id:r.id,task_id:r.task_id,task_revision:a.intent.task_revision,
|
|
86
|
+
intent_fingerprint:a.intent.fingerprint,authority_hash:digest({approval_id:r.id,proposal_hash:r.proposal_hash,intent_fingerprint:a.intent.fingerprint,...proof})};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {managedTaskBranch} from './task-branch.js';
|
|
2
|
+
// Internal push contract. No filesystem, process, credential or network access.
|
|
3
|
+
// A verified local commit is evidence, never authority to update a remote ref.
|
|
4
|
+
import {canonical,digest} from './approval-ledger.js';
|
|
5
|
+
import {repositoryURL} from './repository-gitee.js';
|
|
6
|
+
const fail=c=>{throw Error('DELIVERY_PUSH_'+c);};
|
|
7
|
+
const same=(a,b)=>canonical(a)===canonical(b);
|
|
8
|
+
const sha=x=>typeof x==='string'&&/^[a-f0-9]{64}$/.test(x);
|
|
9
|
+
const oid=x=>typeof x==='string'&&/^[a-f0-9]{40}$/.test(x);
|
|
10
|
+
const branch=managedTaskBranch;
|
|
11
|
+
const action=x=>typeof x==='string'&&/^delivery-[a-f0-9-]{36}$/.test(x);
|
|
12
|
+
const hashed=v=>{if(!v||typeof v!=='object'||Array.isArray(v))return false;const {fingerprint,...b}=v;return sha(fingerprint)&&digest(b)===fingerprint;};
|
|
13
|
+
const fields=(v,keys)=>v&&typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).sort().join(',')===keys.split(',').sort().join(',');
|
|
14
|
+
const keys='schema,kind,source_action_id,source_intent_fingerprint,source_result_fingerprint,repository_id,url,format,branch,ref,commit_oid,parent_oid,tree_oid,manifest_sha256,tested_content,expected_remote_oid,max_pushes,rewrite,delete,tags,publish,deploy,grants_execution,fingerprint';
|
|
15
|
+
export function validatePushPlan(p){
|
|
16
|
+
if(!fields(p,keys)||!hashed(p)||p.schema!==1||p.kind!=='push-commit-v1'||!action(p.source_action_id)||
|
|
17
|
+
!sha(p.source_intent_fingerprint)||!sha(p.source_result_fingerprint)||!Number.isSafeInteger(p.repository_id)||p.repository_id<1||
|
|
18
|
+
repositoryURL(p.url).url!==p.url||p.format!=='sha1'||!branch(p.branch)||p.ref!=='refs/heads/'+p.branch||
|
|
19
|
+
![p.commit_oid,p.parent_oid,p.tree_oid].every(oid)||!sha(p.manifest_sha256)||
|
|
20
|
+
!(p.expected_remote_oid===null||oid(p.expected_remote_oid))||p.expected_remote_oid===p.commit_oid||
|
|
21
|
+
!fields(p.tested_content,'kind,sha256')||!['file_sha256','workspace_manifest_sha256'].includes(p.tested_content.kind)||!sha(p.tested_content.sha256)||
|
|
22
|
+
p.max_pushes!==1||[p.rewrite,p.delete,p.tags,p.publish,p.deploy,p.grants_execution].some(x=>x!==false))fail('PLAN_INVALID');
|
|
23
|
+
return p;
|
|
24
|
+
}
|
|
25
|
+
export function compilePush({source_action_id,commit_intent:i,commit_result:r,repository_id,expected_remote_oid}){
|
|
26
|
+
const p=i?.plan;
|
|
27
|
+
if(!action(source_action_id)||!hashed(i)||i.kind!=='commit-execution-intent-v1'||!hashed(p)||p.kind!=='commit-objects-v1'||
|
|
28
|
+
p.format!=='sha1'||p.persisted!==false||p.grants_execution!==false||r?.kind!=='commit-readback-v1'||r.drained!==true||
|
|
29
|
+
r.intent_fingerprint!==i.fingerprint||r.commit_oid!==p.computed_commit_oid||r.tree_oid!==p.tree_oid||r.branch!==p.branch||
|
|
30
|
+
r.manifest_sha256!==p.manifest_sha256||!same(r.tested_content,p.tested_content))fail('COMMIT_EVIDENCE_REQUIRED');
|
|
31
|
+
const body={schema:1,kind:'push-commit-v1',source_action_id,source_intent_fingerprint:i.fingerprint,source_result_fingerprint:digest(r),
|
|
32
|
+
repository_id,url:i.resource,format:p.format,branch:p.branch,ref:'refs/heads/'+p.branch,commit_oid:p.computed_commit_oid,
|
|
33
|
+
parent_oid:p.parent,tree_oid:p.tree_oid,manifest_sha256:p.manifest_sha256,tested_content:structuredClone(p.tested_content),
|
|
34
|
+
expected_remote_oid,max_pushes:1,rewrite:false,delete:false,tags:false,publish:false,deploy:false,grants_execution:false};
|
|
35
|
+
return validatePushPlan({...body,fingerprint:digest(body)});
|
|
36
|
+
}
|
|
37
|
+
export function pushIntent(store,{task_id,resource,workspace_identity,plan}){
|
|
38
|
+
validatePushPlan(plan);
|
|
39
|
+
const r=store.db.prepare('SELECT * FROM delivery_actions WHERE id=?').get(plan.source_action_id);
|
|
40
|
+
const i=r&&JSON.parse(r.intent),result=r?.result&&JSON.parse(r.result);
|
|
41
|
+
const e=r&&store.db.prepare("SELECT data FROM delivery_events WHERE action_id=? AND type='readback_completed' ORDER BY seq DESC LIMIT 1").get(r.id);
|
|
42
|
+
const proof=e&&JSON.parse(e.data);
|
|
43
|
+
if(!r||r.state!=='completed'||r.dispatched!==1||!Number.isSafeInteger(r.epoch)||r.task_id!==task_id||r.resource!==resource||
|
|
44
|
+
!hashed(i)||i.task_id!==task_id||i.resource!==resource||digest({event_id:r.event_id,approval_id:r.approval_id,intent:i})!==r.fingerprint||
|
|
45
|
+
proof?.epoch!==r.epoch||!same(proof.result,result)||store.db.prepare("SELECT 1 FROM delivery_operations WHERE action_id=? AND state<>'closed'").get(r.id)||
|
|
46
|
+
!same(compilePush({source_action_id:r.id,commit_intent:i,commit_result:result,repository_id:plan.repository_id,expected_remote_oid:plan.expected_remote_oid}),plan)||
|
|
47
|
+
!same(workspace_identity,i.workspace_identity)||resource!==plan.url||store.task(task_id).revision!==i.task_revision)fail('SOURCE_CHANGED');
|
|
48
|
+
const body={schema:1,kind:'push-execution-intent-v1',task_id,task_revision:i.task_revision,resource,
|
|
49
|
+
workspace_identity:structuredClone(workspace_identity),plan:structuredClone(plan)};
|
|
50
|
+
return validatePushIntent({...body,fingerprint:digest(body)});
|
|
51
|
+
}
|
|
52
|
+
export function validatePushIntent(i){
|
|
53
|
+
if(!fields(i,'schema,kind,task_id,task_revision,resource,workspace_identity,plan,fingerprint')||!hashed(i)||i.schema!==1||i.kind!=='push-execution-intent-v1'||
|
|
54
|
+
!Number.isSafeInteger(i.task_revision)||i.task_revision<0)fail('INTENT_INVALID');
|
|
55
|
+
validatePushPlan(i.plan);const w=i.workspace_identity;
|
|
56
|
+
if(i.resource!==i.plan.url||!fields(w,'root,url,branch,base_sha,directory,git_directory')||w.url!==i.resource||w.branch!==i.plan.branch||w.base_sha!==i.plan.parent_oid||
|
|
57
|
+
typeof w.root!=='string'||!w.root.startsWith('/')||w.root.length>2048||/[\x00-\x1f\x7f]/.test(w.root))fail('IDENTITY_INVALID');
|
|
58
|
+
for(const v of [w.directory,w.git_directory])if(!fields(v,'device,inode')||!/^\d{1,30}$/.test(v.device)||!/^\d{1,30}$/.test(v.inode))fail('IDENTITY_INVALID');
|
|
59
|
+
return i;
|
|
60
|
+
}
|
|
61
|
+
export function validatePushReadiness(i,r,now){
|
|
62
|
+
validatePushIntent(i);const p=i.plan,b=r?.baseline;
|
|
63
|
+
if(r?.kind!=='push-preflight-v1'||r.intent_fingerprint!==i.fingerprint||r.drained!==true||r.local_commit_verified!==true||
|
|
64
|
+
r.fast_forward_verified!==(p.expected_remote_oid!==null)||!Number.isSafeInteger(r.observed_at)||r.observed_at>now||now-r.observed_at>5000||
|
|
65
|
+
!fields(b,'kind,intent_fingerprint,workspace_identity,repository_id,url,ref,remote_oid,commit_oid,tree_oid,manifest_sha256,local_snapshot_sha256,fingerprint')||
|
|
66
|
+
!hashed(b)||b.kind!=='push-state-v1'||b.intent_fingerprint!==i.fingerprint||!same(b.workspace_identity,i.workspace_identity)||
|
|
67
|
+
b.repository_id!==p.repository_id||b.url!==p.url||b.ref!==p.ref||b.remote_oid!==p.expected_remote_oid||
|
|
68
|
+
b.commit_oid!==p.commit_oid||b.tree_oid!==p.tree_oid||b.manifest_sha256!==p.manifest_sha256||!sha(b.local_snapshot_sha256))fail('PREFLIGHT_REQUIRED');
|
|
69
|
+
return structuredClone(b);
|
|
70
|
+
}
|
|
71
|
+
export function pushReadback(i,proof,now){
|
|
72
|
+
validatePushIntent(i);const p=i.plan;
|
|
73
|
+
if(proof?.kind!=='push-readback-v1'||proof.intent_fingerprint!==i.fingerprint||proof.drained!==true||
|
|
74
|
+
!Number.isSafeInteger(proof.observed_at)||proof.observed_at>now||now-proof.observed_at>5000||
|
|
75
|
+
proof.repository_id!==p.repository_id||proof.url!==p.url||proof.ref!==p.ref||proof.ref_oid!==p.commit_oid||
|
|
76
|
+
!same(proof.workspace_identity,i.workspace_identity))fail('READBACK_MISMATCH');
|
|
77
|
+
return {kind:'push-readback-v1',intent_fingerprint:i.fingerprint,repository_id:p.repository_id,url:p.url,ref:p.ref,
|
|
78
|
+
commit_oid:p.commit_oid,expected_remote_oid:p.expected_remote_oid,drained:true,observed_at:proof.observed_at,
|
|
79
|
+
remote_acceptance:'ref_observed',runtime_adoption:'not_observed',publish:false,deploy:false};
|
|
80
|
+
}
|
|
81
|
+
// Command construction is not execution. The eventual transport must independently
|
|
82
|
+
// check ancestry before invoking the CAS lease: --force-with-lease alone permits rewrites.
|
|
83
|
+
export function pushCommand(p){
|
|
84
|
+
validatePushPlan(p);const r=repositoryURL(p.url);
|
|
85
|
+
return ['push','--porcelain','--no-verify','--no-follow-tags','--signed=false','--recurse-submodules=no',
|
|
86
|
+
'--force-with-lease='+p.ref+':'+(p.expected_remote_oid??''),'--','git@gitee.com:'+r.owner+'/'+r.repo+'.git',p.commit_oid+':'+p.ref];
|
|
87
|
+
}
|
|
88
|
+
export function remoteReadCommand(p){
|
|
89
|
+
validatePushPlan(p);const r=repositoryURL(p.url);
|
|
90
|
+
return ['ls-remote','--refs','--exit-code','--','git@gitee.com:'+r.owner+'/'+r.repo+'.git',p.ref];
|
|
91
|
+
}
|
|
92
|
+
export function parseRemoteRef(p,{code,stdout}){
|
|
93
|
+
validatePushPlan(p);
|
|
94
|
+
if(typeof stdout!=='string'||stdout.length>1024)fail('REMOTE_UNKNOWN');
|
|
95
|
+
if(code===2&&stdout==='')return null;
|
|
96
|
+
const m=/^([a-f0-9]{40})\t(refs\/heads\/[^\r\n]+)\n$/.exec(stdout);
|
|
97
|
+
if(code!==0||!m||m[2]!==p.ref)fail('REMOTE_UNKNOWN');return m[1];
|
|
98
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Operator-only reconciliation, never invoked by startup, model tools or a timer.
|
|
2
|
+
// Sealed process evidence can close an operation, not release a task or prove push.
|
|
3
|
+
const fail=c=>{throw Error('DELIVERY_PUSH_RECOVERY_'+c);};
|
|
4
|
+
export class DeliveryPushRecovery {
|
|
5
|
+
constructor({journal,receipts,executor,quiescent=()=>false,enabled=false}){Object.assign(this,{j:journal,receipts,executor,quiescent,enabled});}
|
|
6
|
+
guard(id,epoch){
|
|
7
|
+
if(this.enabled!==true)fail('DISABLED');if(this.quiescent()!==true)fail('NOT_QUIESCENT');
|
|
8
|
+
const r=this.j.fence(id,epoch,['needs_review']),i=this.j.intent(r);
|
|
9
|
+
if(i.kind!=='push-execution-intent-v1'||!r.dispatched||this.j.store.task(r.task_id).revision!==i.task_revision)fail('ACTION_INVALID');return i;
|
|
10
|
+
}
|
|
11
|
+
inspect(id,epoch){
|
|
12
|
+
this.guard(id,epoch);
|
|
13
|
+
return {action_id:id,epoch,operations:this.j.db.prepare('SELECT id,state FROM delivery_operations WHERE action_id=? ORDER BY rowid').all(id).map(o=>{
|
|
14
|
+
try{return {...o,evidence:this.receipts.inspect(o.id)};}catch{return {...o,evidence:null,error:'PROCESS_RECEIPT_UNVERIFIED'};}
|
|
15
|
+
}),remote_checked_now:false,task_released:false,retry_authorized:false};
|
|
16
|
+
}
|
|
17
|
+
closeOperation(id,epoch,operation_id,fingerprint){
|
|
18
|
+
this.guard(id,epoch);const o=this.j.operation(operation_id);
|
|
19
|
+
if(o.action_id!==id||o.epoch!==epoch)fail('OPERATION_MISMATCH');
|
|
20
|
+
return this.receipts.reconcile(operation_id,fingerprint);
|
|
21
|
+
}
|
|
22
|
+
async settle(id,epoch,{signal}={}){
|
|
23
|
+
const i=this.guard(id,epoch);if(this.j.unfinished(id))fail('DRAIN_REQUIRED');
|
|
24
|
+
try{
|
|
25
|
+
await this.executor.readback(i,{signal});this.guard(id,epoch);
|
|
26
|
+
return {...this.j.finish(id,epoch),retry_authorized:false};
|
|
27
|
+
}catch{
|
|
28
|
+
// Retain uncertainty without acquiring a new generation or repeating a send.
|
|
29
|
+
this.guard(id,epoch);this.j.event(id,'remote_readback_failed',{epoch,error:'REMOTE_READBACK_UNVERIFIED'});return {action_id:id,state:'needs_review',error:'REMOTE_READBACK_UNVERIFIED',task_released:false,retry_authorized:false};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|