@milpa/comal 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/backend/contracts/remote-commands-v1.json +12 -0
- package/backend/contracts/remote-workspace-v1.schema.json +287 -0
- package/governance/chrome-task-v10.json +323 -0
- package/governance/chrome-task-v11.json +355 -0
- package/governance/chrome-task-v12.json +387 -0
- package/governance/chrome-task-v13.json +419 -0
- package/governance/chrome-task-v3.json +99 -0
- package/governance/chrome-task-v4.json +131 -0
- package/governance/chrome-task-v5.json +163 -0
- package/governance/chrome-task-v6.json +195 -0
- package/governance/chrome-task-v7.json +227 -0
- package/governance/chrome-task-v8.json +259 -0
- package/governance/chrome-task-v9.json +291 -0
- package/governance/source-task-v1.json +1 -0
- package/governance/source-task-v2.json +1 -0
- package/package.json +15 -1
- package/src/agent-recovery-proof.mjs +12 -1
- package/src/agent.mjs +94 -30
- package/src/audit.mjs +21 -6
- package/src/auto-guard.mjs +48 -1
- package/src/chrome-candidate-recovery-proof.mjs +47 -0
- package/src/chrome-recovery-proof.mjs +6 -2
- package/src/chrome-source-syntax.mjs +93 -0
- package/src/cli.mjs +161 -56
- package/src/codex-provider.mjs +3 -2
- package/src/connection-settings.mjs +204 -0
- package/src/context-pressure.mjs +2 -1
- package/src/durable-agent-runs.mjs +5 -11
- package/src/event-channels.mjs +8 -0
- package/src/file-recovery-proof.mjs +11 -3
- package/src/file-recovery.mjs +24 -0
- package/src/gemini-provider.mjs +6 -1
- package/src/git-agent-revision.mjs +12 -0
- package/src/git-agent-workspaces.mjs +143 -0
- package/src/git-integration-lineage.mjs +20 -0
- package/src/git-integration-operations.mjs +200 -0
- package/src/git-integration-recovery-proof.mjs +70 -0
- package/src/git-page-cli-bridge.mjs +61 -0
- package/src/git-page-evidence.mjs +30 -0
- package/src/git-page-identity.mjs +10 -0
- package/src/git-page-proof.mjs +96 -0
- package/src/git-page-settlement.mjs +21 -0
- package/src/git-page-verification.mjs +134 -0
- package/src/git-plumbing.mjs +212 -0
- package/src/git-process-worker.mjs +50 -0
- package/src/git-source.mjs +110 -0
- package/src/git-workspaces.mjs +519 -0
- package/src/goals.mjs +13 -7
- package/src/house.mjs +246 -20
- package/src/i18n/cli-es.mjs +4 -0
- package/src/i18n/es.mjs +35 -0
- package/src/judges.mjs +27 -11
- package/src/mcp-runtime.mjs +15 -3
- package/src/model-controls.mjs +11 -1
- package/src/model-deadline.mjs +4 -3
- package/src/native-refusal.mjs +38 -0
- package/src/onboarding.mjs +88 -24
- package/src/openai-provider.mjs +14 -7
- package/src/operations.mjs +9 -2
- package/src/output-recovery.mjs +48 -0
- package/src/peer-tools.mjs +18 -8
- package/src/permission-controls.mjs +232 -0
- package/src/permission-rules.mjs +312 -0
- package/src/permissions.mjs +113 -30
- package/src/plugin-catalog.mjs +84 -0
- package/src/plugin-config.mjs +48 -6
- package/src/plugin-controls.mjs +332 -0
- package/src/plugin-notices.mjs +88 -0
- package/src/plugin-trust.mjs +104 -0
- package/src/plugins/chrome-lab/bridge.mjs +38 -12
- package/src/plugins/chrome-lab/client.mjs +103 -25
- package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
- package/src/plugins/chrome-lab/index.mjs +116 -26
- package/src/plugins/chrome-lab/project.mjs +30 -2
- package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
- package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
- package/src/plugins/open-relay/actions.mjs +205 -0
- package/src/plugins/open-relay/bound.mjs +47 -0
- package/src/plugins/open-relay/client.mjs +158 -0
- package/src/plugins/open-relay/dossier.mjs +119 -0
- package/src/plugins/open-relay/index.mjs +153 -0
- package/src/plugins/open-relay/lookup.mjs +45 -0
- package/src/plugins/open-relay/notices.mjs +79 -0
- package/src/plugins/open-relay/watch.mjs +127 -0
- package/src/plugins.mjs +72 -29
- package/src/private-fs.mjs +40 -0
- package/src/process-lock.mjs +24 -13
- package/src/provider-auth.mjs +14 -3
- package/src/provider.mjs +11 -2
- package/src/read-only-check.mjs +7 -3
- package/src/remote-control.mjs +29 -4
- package/src/remote-workspace.mjs +50 -0
- package/src/scope-ceiling.mjs +56 -0
- package/src/scoped-policy.mjs +39 -0
- package/src/secret-login.mjs +68 -0
- package/src/session-bus.mjs +41 -7
- package/src/session-projections-candidate.mjs +119 -0
- package/src/session-projections.mjs +119 -0
- package/src/session-read-cache.mjs +23 -3
- package/src/sessions.mjs +35 -11
- package/src/source-editor-role.mjs +13 -0
- package/src/source-ownership.mjs +73 -0
- package/src/source-proposal-proof.mjs +55 -0
- package/src/source-proposals.mjs +118 -0
- package/src/source-requests.mjs +148 -0
- package/src/source-task-proof.mjs +24 -0
- package/src/source-task-revision-proof.mjs +44 -0
- package/src/subagents.mjs +187 -87
- package/src/tool-packs.mjs +5 -5
- package/src/tools.mjs +82 -15
- package/src/trusted-context.mjs +5 -1
- package/src/tui/connection-transition.mjs +1 -1
- package/src/tui/controller.mjs +8 -4
- package/src/tui/discovery.mjs +24 -12
- package/src/tui/inbound-blocks.mjs +55 -0
- package/src/tui/present.mjs +504 -0
- package/src/tui/screen.mjs +49 -13
- package/src/windows-private.ps1 +61 -0
- package/src/work-assertions.mjs +6 -2
- package/src/work-plan.mjs +34 -8
- package/src/work-state.mjs +15 -9
- package/src/workflows/chrome-page-cases.mjs +156 -0
- package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
- package/src/workflows/chrome-page-diagnostics.mjs +113 -0
- package/src/workflows/chrome-page-law.mjs +48 -0
- package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
- package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
- package/src/workflows/chrome-page-task.mjs +429 -0
- package/src/workflows/chrome-page-verifier.mjs +123 -0
- package/src/workflows/chrome-source.mjs +73 -0
- package/src/workflows/chrome-supplied-inputs.mjs +43 -0
- package/src/workflows/chrome-task-adoption.mjs +122 -0
- package/src/workflows/chrome-task.mjs +379 -74
- package/src/workflows/source-task-v1.mjs +124 -0
- package/src/workflows/source-task-v2.mjs +227 -0
- package/src/workflows/source-task.mjs +13 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Acknowledged close/unload may precede native inventory disappearance. Poll
|
|
2
|
+
// reads only on the original connection; never repeats the cleanup effects.
|
|
3
|
+
import {setTimeout as sleep} from 'node:timers/promises';
|
|
4
|
+
import {digest} from './operations.mjs';
|
|
5
|
+
const fail=message=>Object.assign(new Error(message),{code:'GIT_PAGE_CLEANUP_UNCERTAIN'});
|
|
6
|
+
const endpoint=status=>({connection_id:status.connection_id,browser_endpoint_id:status.browser_endpoint_id,ssh_host:status.ssh_host,remote_root:status.remote_root});
|
|
7
|
+
export const PAGE_CLEANUP_SETTLEMENT=Object.freeze({timeout_ms:5000,poll_ms:100,max_reads:40});
|
|
8
|
+
export async function settlePageAbsence({call,targetId,project,originalStatus,originalTarget,signal}){
|
|
9
|
+
const expected=endpoint(originalStatus),originalExtensions=(originalStatus.extensions||[]).filter(e=>e.project===project),extensionId=e=>e.extension_id||e.id;if(!expected.connection_id)throw fail('Original cleanup connection identity is unavailable.');
|
|
10
|
+
const deadline=AbortSignal.any([...(signal?[signal]:[]),AbortSignal.timeout(PAGE_CLEANUP_SETTLEMENT.timeout_ms)]);
|
|
11
|
+
try{for(let reads=0;reads<PAGE_CLEANUP_SETTLEMENT.max_reads;reads++){
|
|
12
|
+
deadline.throwIfAborted();const targets=await call('Targets',{},deadline),status=await call('Status',{},deadline);
|
|
13
|
+
if(targets.truncated||status.extensions_truncated||digest(endpoint(status))!==digest(expected))throw fail('Cleanup inventory/endpoint changed; no effect is replayed.');
|
|
14
|
+
const target=targets.targets.find(t=>t.target_id===targetId),extensions=status.extensions.filter(e=>e.project===project);
|
|
15
|
+
if(target&&originalTarget&&(target.type!==originalTarget.type||target.url!==originalTarget.url)||extensions.length>1||extensions.length===1&&(originalExtensions.length!==1||extensionId(extensions[0])!==extensionId(originalExtensions[0])))throw fail('An owned cleanup identity was replaced during settlement; no effect is replayed.');
|
|
16
|
+
if(!targets.targets.some(t=>t.target_id===targetId)&&!status.extensions.some(e=>e.project===project))return{targets,status,reads:reads+1};
|
|
17
|
+
await sleep(PAGE_CLEANUP_SETTLEMENT.poll_ms,undefined,{signal:deadline});
|
|
18
|
+
}}catch(error){if(signal?.aborted)throw signal.reason;if(deadline.aborted)throw fail('Owned native absence did not settle within its read-only deadline.');throw error;}
|
|
19
|
+
throw fail('Owned native absence did not settle within its read-only read bound.');
|
|
20
|
+
}
|
|
21
|
+
export const pageSettlementImplementation=()=>digest({contract:PAGE_CLEANUP_SETTLEMENT,functions:[settlePageAbsence,endpoint,fail].map(fn=>Function.prototype.toString.call(fn))});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Trusted host registration and workspace-bound page adapter. Recipes are host
|
|
2
|
+
// code, not a sandbox, model-loadable modules, or retrospective attestations.
|
|
3
|
+
import {randomUUID,createHash} from 'node:crypto';
|
|
4
|
+
import {digest,immutable} from './operations.mjs';
|
|
5
|
+
import {normalizeTask} from './workflows/chrome-task.mjs';
|
|
6
|
+
import {prepareChromePageTask} from './workflows/chrome-page-task.mjs';
|
|
7
|
+
import {PAGE_CASE_IMPLEMENTATION_DIGEST} from './workflows/chrome-page-cases.mjs';
|
|
8
|
+
import {PAGE_LAW_IMPLEMENTATION_DIGEST} from './workflows/chrome-page-law.mjs';
|
|
9
|
+
import {assertPageVerifier} from './workflows/chrome-page-verifier.mjs';
|
|
10
|
+
import {hostCodeIdentity} from './git-page-identity.mjs';
|
|
11
|
+
import {settlePageAbsence,pageSettlementImplementation} from './git-page-settlement.mjs';
|
|
12
|
+
import {snapshotProject} from './plugins/chrome-lab/project.mjs';
|
|
13
|
+
import {isPageVerification,pageReservation,pageCaseRevision,pageProofImplementation,pageRecipeReference} from './git-page-proof.mjs';
|
|
14
|
+
|
|
15
|
+
const fail=(message,code='GIT_PAGE_RECIPE')=>Object.assign(new Error(message),{code});
|
|
16
|
+
const descriptors=new WeakSet();
|
|
17
|
+
export class GitPageRegistry {
|
|
18
|
+
#recipes=new Map(); #runtimes=new Map(); #busy=new Set(); #sealed=false;
|
|
19
|
+
acquire(resourceId){if(this.#busy.has(resourceId))throw fail('The host page resource has another active command/cleanup.','GIT_PAGE_BUSY');this.#busy.add(resourceId);let active=true;return()=>{if(active){active=false;this.#busy.delete(resourceId);}};}
|
|
20
|
+
catalog(){return [...this.#recipes.values()].map(({descriptor,purpose})=>({name:descriptor.name,revision:descriptor.revision,digest:descriptor.digest,purpose}));}
|
|
21
|
+
seal(){if(!this.#recipes.size)throw fail('Register at least one host page recipe before constructing page-enabled tools.');this.#sealed=true;return immutable(this.catalog());}
|
|
22
|
+
register({name,revision,configuration_digest,task,verifier,runtimeFactory,fixture_sha256,purpose='Host-registered bounded page check'}){
|
|
23
|
+
if(this.#sealed)throw fail('Host recipe registry is sealed for this tool catalogue. Construct a fresh registry and tool runtime to change it.','GIT_PAGE_REGISTRY_SEALED');
|
|
24
|
+
if(this.#recipes.size>=24||typeof purpose!=='string'||!purpose||purpose.length>240||!revision||revision.length>120)throw fail('Host recipe catalogue exceeds its public bound.');
|
|
25
|
+
if(!/^[a-z][a-z0-9_-]{0,79}$/.test(name||'')||typeof revision!=='string'||!revision||!/^[a-f0-9]{64}$/.test(configuration_digest||'')||typeof runtimeFactory!=='function'||!/^[a-f0-9]{64}$/.test(fixture_sha256||'')||this.#recipes.has(name))throw fail('A unique versioned host page recipe is required.');
|
|
26
|
+
const template=normalizeTask({...task,surface:'page',project:'git-page-recipe'});
|
|
27
|
+
if(!template.verification.cases?.length)throw fail('This page adapter requires an explicit complete ordered case contract.');
|
|
28
|
+
assertPageVerifier(verifier,template.verification);
|
|
29
|
+
const implementation=pageImplementation(runtimeFactory,this);
|
|
30
|
+
const value={name,revision,configuration_digest,task:template,verifier:verifier.descriptor,fixture_sha256,implementation};
|
|
31
|
+
const descriptor=immutable({...value,digest:digest(value)});descriptors.add(descriptor);
|
|
32
|
+
if(Buffer.byteLength(JSON.stringify([...this.catalog(),{name,revision,digest:descriptor.digest,purpose}]))>8000)throw fail('Host recipe public catalogue exceeds its byte bound.');
|
|
33
|
+
this.#recipes.set(name,{descriptor,verifier,runtimeFactory,purpose});return descriptor;
|
|
34
|
+
}
|
|
35
|
+
resolve(name){const entry=this.#recipes.get(name);if(!entry)throw fail('No host page recipe is registered under this name.');return entry.descriptor;}
|
|
36
|
+
bindings(verification){return isPageVerification(verification)?[this.resolve(verification.checks[0].recipe)]:undefined;}
|
|
37
|
+
references(verification){return this.bindings(verification)?.map(pageRecipeReference);}
|
|
38
|
+
assert(bindings,verification){
|
|
39
|
+
const expected=this.bindings(verification);
|
|
40
|
+
if(digest(expected?.map(pageRecipeReference)??null)!==digest(bindings??null))throw fail('The original host page recipe changed.','GIT_PAGE_RECIPE_CHANGED');return expected;
|
|
41
|
+
}
|
|
42
|
+
async adapter({context,tools,house,policy,prepared,reservation,recipe}){
|
|
43
|
+
const entry=this.#recipes.get(recipe.name);
|
|
44
|
+
if(!entry||entry.descriptor.digest!==recipe.digest||!descriptors.has(entry.descriptor)||digest(pageImplementation(entry.runtimeFactory,this))!==digest(recipe.implementation))throw fail('The original page recipe is unavailable.','GIT_PAGE_RECIPE_CHANGED');
|
|
45
|
+
const {cwd,store,session_id:sessionId,owner}=context,key=reservation.resource_id+':'+reservation.attempt_id;
|
|
46
|
+
const native=await store.load(sessionId);pageReservation(native,reservation);
|
|
47
|
+
if(reservation.kernel_identity!==context.kernel_identity||digest(reservation.host_implementation)!==digest(recipe.implementation)||reservation.case_revision!==pageCaseRevision(reservation))throw fail('The reserved page implementation/Case identity changed.','GIT_PAGE_RECIPE_CHANGED');
|
|
48
|
+
const task=normalizeTask({...recipe.task,project:'git-'+reservation.resource_id});
|
|
49
|
+
if(digest(task)!==reservation.task_digest||reservation.recipe_digest!==recipe.digest||reservation.candidate_commit!==prepared.candidate_commit||reservation.owner_digest!==digest(owner))throw fail('Page adapter does not own this reserved candidate.');
|
|
50
|
+
const source=await snapshotProject(cwd,task.directory,{signal:context.signal}),sourceDigest=digest(source.files.map(({path,sha256})=>({path,sha256})));
|
|
51
|
+
if(sourceDigest!==reservation.source_digest)throw fail('Reserved publication bytes changed.','GIT_VERIFICATION_SOURCE_CHANGED');
|
|
52
|
+
let resource=this.#runtimes.get(key);
|
|
53
|
+
if(!resource){
|
|
54
|
+
const runtime=await entry.runtimeFactory({resource_id:context.resource_id,attempt_id:context.attempt_id,cwd,owner:immutable(structuredClone(owner)),recipe});
|
|
55
|
+
if(typeof runtime?.install!=='function'||typeof runtime.close!=='function')throw fail('Host page runtime must provide workspace installation and explicit close.');
|
|
56
|
+
resource={runtime,owner:digest(owner),recipe:recipe.digest,houses:new WeakSet(),adapters:[]};this.#runtimes.set(key,resource);
|
|
57
|
+
}
|
|
58
|
+
if(resource.owner!==digest(owner)||resource.recipe!==recipe.digest)throw fail('Page runtime lease belongs to another source owner.');
|
|
59
|
+
if(resource.houses.has(house))throw fail('A page adapter is already installed on this verifier House.');
|
|
60
|
+
await resource.runtime.install({cwd,store,sessionId,house,policy,tools,signal:context.signal});resource.houses.add(house);
|
|
61
|
+
let inventory=native.rows.filter(r=>r.type==='git_page_runtime_inventory');
|
|
62
|
+
if(!inventory.length){
|
|
63
|
+
if(native.rows.some(r=>r.type==='house_proposal'&&r.call_id===reservation.publish_call_id))throw fail('Original pre-publication inventory is missing.','GIT_PAGE_EVIDENCE');
|
|
64
|
+
const targets=JSON.parse(await tools.execute('plugin__chrome-lab__Targets',{})),status=JSON.parse(await tools.execute('plugin__chrome-lab__Status',{}));
|
|
65
|
+
if(targets.truncated||status.extensions_truncated)throw fail('Native ownership inventory is truncated.','GIT_PAGE_EVIDENCE');
|
|
66
|
+
const value={targets:targets.targets,extensions:status.extensions,other_extensions_count:status.other_extensions_count};await store.append(sessionId,{type:'git_page_runtime_inventory',source:'git_page_host',reservation_digest:digest(reservation),inventory:value,inventory_digest:digest(value)});inventory=[{inventory:value,inventory_digest:digest(value),reservation_digest:digest(reservation)}];
|
|
67
|
+
}
|
|
68
|
+
if(inventory.length!==1||inventory[0].reservation_digest!==digest(reservation)||inventory[0].inventory_digest!==digest(inventory[0].inventory))throw fail('Original native ownership inventory changed.','GIT_PAGE_EVIDENCE');
|
|
69
|
+
const originalInventory=inventory[0].inventory;
|
|
70
|
+
const workflow=await prepareChromePageTask({cwd,store,sessionId,house,policy,executor:tools.executor,task,verifier:entry.verifier,source:'host_git_page_recipe',caseRevision:reservation.case_revision,caseImplementation:recipe.implementation.case});
|
|
71
|
+
const originals=new Map(['ChromePublish','ChromeTest','ChromePageCase','ChromePageBehavior'].map(name=>[name,house.registry.get(name)]));
|
|
72
|
+
const execute=async(name,callId,signal)=>{
|
|
73
|
+
for(const [name,operation] of originals)if(house.registry.get(name)!==operation)throw fail('Original page operation was replaced.','GIT_PAGE_EVIDENCE');
|
|
74
|
+
return JSON.parse(await workflow.wrap(tools.executor).execute(name,{}, {callId,signal}));
|
|
75
|
+
};
|
|
76
|
+
const adapter={task,source_digest:sourceDigest,async run(signal){
|
|
77
|
+
const book=await store.load(sessionId);
|
|
78
|
+
if(book.rows.some(r=>r.type==='house_proposal'&&(r.call_id===reservation.publish_call_id||r.call_id===reservation.test_call_id)))throw fail('An original page effect was already proposed; it is not replayed.','GIT_VERIFICATION_UNCERTAIN');
|
|
79
|
+
await execute('ChromePublish',reservation.publish_call_id,signal);
|
|
80
|
+
const published=await store.load(sessionId),state=published.rows.findLast(r=>r.type==='chrome_task_checkpoint')?.state;
|
|
81
|
+
if(!state?.binding||state.source_digest!==sourceDigest||!state.deployment||state.deployment.file_manifest_sha256!==state.file_manifest_sha256)throw fail('The original deployed candidate is unavailable.');
|
|
82
|
+
await store.append(sessionId,{type:'git_page_publication_bound',source:'git_page_host',reservation_digest:digest(reservation),source_digest:sourceDigest,binding:state.binding,publication:{attempt_id:state.attempt_id,file_manifest_sha256:state.file_manifest_sha256,extension_id:state.deployment.extension_id}});
|
|
83
|
+
return execute('ChromeTest',reservation.test_call_id,signal);
|
|
84
|
+
},final:(callId,signal)=>execute('ChromeTest',callId,signal),async cleanup(){
|
|
85
|
+
const book=await store.load(sessionId),state=book.rows.findLast(r=>r.type==='chrome_task_checkpoint')?.state;
|
|
86
|
+
if(!state?.binding||!state.deployment)throw fail('Owned page cleanup lacks its original publication.','GIT_PAGE_CLEANUP');
|
|
87
|
+
workflow.dispose();
|
|
88
|
+
const prefix='plugin__chrome-lab__',remove=house.guard('git-page-cleanup',view=>{
|
|
89
|
+
if(!view.operation.startsWith(prefix))return false;
|
|
90
|
+
const name=view.operation.slice(prefix.length);
|
|
91
|
+
return ['Targets','Status'].includes(name)||name==='CloseTab'&&view.input.target_id===state.binding.target_id||name==='Unload'&&view.input.project===task.project;
|
|
92
|
+
},{revision:'comal-git-page-cleanup-v1',configuration_digest:digest({project:task.project,binding:state.binding,extension_id:state.deployment.extension_id})});
|
|
93
|
+
try{
|
|
94
|
+
const call=async(name,input={},signal=context.signal)=>JSON.parse(await tools.execute(prefix+name,input,{signal}));
|
|
95
|
+
const targets=await call('Targets');if(targets.truncated)throw fail('Owned cleanup target inventory is truncated.','GIT_PAGE_CLEANUP');
|
|
96
|
+
const target=targets.targets.find(t=>t.target_id===state.binding.target_id);
|
|
97
|
+
if(target&&(target.type!=='page'||target.url!==state.binding.url))throw fail('Owned cleanup target was replaced.','GIT_PAGE_CLEANUP');
|
|
98
|
+
const status=await call('Status'),extensions=status.extensions.filter(e=>e.project===task.project);
|
|
99
|
+
if(status.extensions_truncated||extensions.length>1||extensions.length===1&&(extensions[0].extension_id||extensions[0].id)!==state.deployment.extension_id)throw fail('Owned cleanup extension was replaced.','GIT_PAGE_CLEANUP');
|
|
100
|
+
if(target){const closed=await call('CloseTab',{target_id:state.binding.target_id});if(closed.success!==true&&!(closed.closed===true&&closed.already_closed===true&&closed.target_id===state.binding.target_id))throw fail('Owned tab close was not acknowledged.','GIT_PAGE_CLEANUP');}
|
|
101
|
+
if(extensions.length){const unloaded=await call('Unload',{project:task.project});if(unloaded.unloaded!==true||unloaded.extension_id!==state.deployment.extension_id)throw fail('Owned extension unload was not acknowledged.','GIT_PAGE_CLEANUP');}
|
|
102
|
+
const settled=await settlePageAbsence({call,targetId:state.binding.target_id,project:task.project,originalStatus:status,originalTarget:target,signal:context.signal}),afterTargets=settled.targets,afterStatus=settled.status;
|
|
103
|
+
if(afterTargets.truncated||afterStatus.extensions_truncated||afterTargets.targets.some(t=>t.target_id===state.binding.target_id)||afterStatus.extensions.some(e=>e.project===task.project))throw fail('Owned resource absence was not observed.','GIT_PAGE_CLEANUP');
|
|
104
|
+
const foreign={targets:afterTargets.targets.filter(t=>t.target_id!==state.binding.target_id),extensions:afterStatus.extensions.filter(e=>e.project!==task.project),other_extensions_count:afterStatus.other_extensions_count},beforeForeign={targets:originalInventory.targets.filter(t=>t.target_id!==state.binding.target_id),extensions:originalInventory.extensions.filter(e=>e.project!==task.project),other_extensions_count:originalInventory.other_extensions_count};
|
|
105
|
+
if(digest(foreign)!==digest(beforeForeign))throw fail('Foreign native inventory changed; no foreign cleanup is attempted.','GIT_PAGE_CLEANUP');
|
|
106
|
+
await store.append(sessionId,{type:'git_page_cleanup',source:'git_page_host',reservation_digest:digest(reservation),target_id:state.binding.target_id,extension_id:state.deployment.extension_id,owned_absent:true,settlement_reads:settled.reads,observable_other_resources_unchanged:true,unrelated_extension_identity:'not_enumerated',before_inventory_digest:digest(originalInventory),after_inventory_digest:digest(foreign)});
|
|
107
|
+
}finally{remove();}
|
|
108
|
+
},dispose:()=>workflow.dispose()};
|
|
109
|
+
resource.adapters.push(adapter);return adapter;
|
|
110
|
+
}
|
|
111
|
+
// Explicit host lifecycle. Closing the transport does not claim remote tab or
|
|
112
|
+
// extension deletion. The host runtime must retain/report its owned cleanup.
|
|
113
|
+
async close(resourceId,attemptId,{cleanup=true}={}){
|
|
114
|
+
const key=resourceId+':'+attemptId,resource=this.#runtimes.get(key);if(!resource)return{closed:false};
|
|
115
|
+
if(cleanup&&resource.adapters.length){if(resource.cleanupAttempted)throw fail('Cleanup was already attempted. Original effects are not replayed; retain and inspect their native results.','GIT_PAGE_CLEANUP_UNCERTAIN');resource.cleanupAttempted=true;await resource.adapters.at(-1).cleanup();}
|
|
116
|
+
for(const adapter of resource.adapters)adapter.dispose();await resource.runtime.close();this.#runtimes.delete(key);return{closed:true};
|
|
117
|
+
}
|
|
118
|
+
async closeAll(options){for(const key of [...this.#runtimes.keys()]){const at=key.lastIndexOf(':');await this.close(key.slice(0,at),key.slice(at+1),options);}}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function reservePageCheck({context,prepared,recipe,check,phase}){
|
|
122
|
+
const task=normalizeTask({...recipe.task,project:'git-'+context.resource_id});
|
|
123
|
+
const snapshot=await snapshotProject(context.cwd,task.directory,{signal:context.signal});
|
|
124
|
+
if(snapshot.files.find(file=>file.path===task.fixture.file)?.sha256!==recipe.fixture_sha256)throw fail('The host-owned page fixture differs from its original recipe.','GIT_PAGE_FIXTURE_CHANGED');
|
|
125
|
+
const value={
|
|
126
|
+
format:'comal-git-page-check/1',resource_id:context.resource_id,attempt_id:context.attempt_id,check_id:check.id,
|
|
127
|
+
owner_digest:digest(context.owner),candidate_commit:prepared.candidate_commit,source_identity:prepared.source_identity,
|
|
128
|
+
recipe_digest:recipe.digest,task_digest:digest(task),source_digest:digest(snapshot.files.map(({path,sha256})=>({path,sha256}))),file_manifest_sha256:physicalHash(snapshot),
|
|
129
|
+
publish_call_id:randomUUID(),test_call_id:randomUUID(),native_binding:context.binding,host_implementation:recipe.implementation,kernel_identity:context.kernel_identity,
|
|
130
|
+
};return phase('page_reservation',{...value,case_revision:pageCaseRevision(value)});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function pageImplementation(runtimeFactory,registry){return {format:'comal-git-page-implementation/1',case:PAGE_CASE_IMPLEMENTATION_DIGEST,law:PAGE_LAW_IMPLEMENTATION_DIGEST,workflow:digest(Function.prototype.toString.call(prepareChromePageTask)),adapter:hostCodeIdentity(registry,GitPageRegistry),identity_function:digest(Function.prototype.toString.call(hostCodeIdentity)),runtime_factory:digest(Function.prototype.toString.call(runtimeFactory)),proof:pageProofImplementation(),settlement:pageSettlementImplementation()};}
|
|
134
|
+
const physicalHash=snapshot=>{const json=JSON.stringify(snapshot.files.map(({path,sha256})=>({path,sha256})));return createHash('sha256').update(json).digest('hex');};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import {spawn,execFile} from 'node:child_process';
|
|
2
|
+
import {promisify} from 'node:util';
|
|
3
|
+
import {mkdir,lstat,stat,realpath,readFile,readlink,access} from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
import {processDead,processIdentity} from './process-lock.mjs';
|
|
7
|
+
import {ensurePrivateDirectory,assertPrivateDirectory} from './private-fs.mjs';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import {performance} from 'node:perf_hooks';
|
|
10
|
+
import {constants} from 'node:fs';
|
|
11
|
+
import {createHash} from 'node:crypto';
|
|
12
|
+
const exec=promisify(execFile);
|
|
13
|
+
export const gitFailure=(message,code='GIT_RESOURCE_UNCERTAIN')=>Object.assign(new Error(message),{code});
|
|
14
|
+
export async function privateGitDirectory(directory){
|
|
15
|
+
directory=path.resolve(directory);let ancestor=directory;
|
|
16
|
+
for(;;){try{if(await realpath(ancestor)!==ancestor)throw gitFailure('Git host state has a noncanonical ancestor.');break;}catch(error){if(error.code!=='ENOENT')throw error;ancestor=path.dirname(ancestor);}}
|
|
17
|
+
try{await ensurePrivateDirectory(directory);await assertPrivateDirectory(directory);}catch{throw gitFailure('Git host state requires a private canonical directory.');}
|
|
18
|
+
return directory;
|
|
19
|
+
}
|
|
20
|
+
const baseArgs=['--no-pager','--no-optional-locks','--literal-pathspecs','-c','core.fsmonitor=false','-c','core.hooksPath=/dev/null','-c','core.autocrlf=false','-c','commit.gpgsign=false'];
|
|
21
|
+
export function gitHostEnvironment(extra={}){
|
|
22
|
+
return{...Object.fromEntries(Object.entries(process.env).filter(([key])=>!key.startsWith('GIT_'))),GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_TERMINAL_PROMPT:'0',GIT_OPTIONAL_LOCKS:'0',LC_ALL:'C',...extra};
|
|
23
|
+
}
|
|
24
|
+
export function gitWorkerGone(fact){
|
|
25
|
+
if(!fact?.identity||!processDead(fact.identity))return false;
|
|
26
|
+
if(process.platform==='win32')return false; // A dead worker alone cannot prove its descendant is gone.
|
|
27
|
+
if(fact.process_group!==fact.identity.pid)return false;
|
|
28
|
+
try{process.kill(-fact.process_group,0);return false;}catch(error){return error.code==='ESRCH';}
|
|
29
|
+
}
|
|
30
|
+
export async function readGit(cwd,args,{signal,gitDir,env={},maxOutput=4*1024*1024,allowed=[]}={}){
|
|
31
|
+
if(!['rev-parse','show-ref','for-each-ref','ls-tree','ls-files','cat-file','check-ref-format'].includes(args[0])&&!(args[0]==='worktree'&&args[1]==='list'))throw gitFailure('This Git command is not on the native observation surface.','GIT_COMMAND_SCOPE');
|
|
32
|
+
if(args[0]==='cat-file'&&!['blob','commit','tree','-s','-t','-e'].includes(args[1]))throw gitFailure('Git object inspection cannot request project conversions.','GIT_COMMAND_SCOPE');
|
|
33
|
+
try{return{code:0,stdout:(await exec('git',[...baseArgs,...gitDir?['--git-dir='+gitDir]:[],...args],{cwd,env:gitHostEnvironment(env),signal,timeout:15000,maxBuffer:maxOutput,encoding:'buffer'})).stdout};}
|
|
34
|
+
catch(error){if(allowed.includes(error.code))return{code:error.code,stdout:error.stdout||Buffer.alloc(0)};if(signal?.aborted)throw signal.reason;throw gitFailure('Git observation failed or exceeded its bound.','GIT_OBSERVATION_FAILED');}
|
|
35
|
+
}
|
|
36
|
+
// Two fixed source-selected callers share one executor. No serialized backend.
|
|
37
|
+
export async function mutateGit(cwd,args,options={}){return mutateGitCommand(cwd,args,options,false);}
|
|
38
|
+
export async function mutateGitWithChildWitness(cwd,args,options={}){return mutateGitCommand(cwd,args,options,true);}
|
|
39
|
+
async function mutateGitCommand(cwd,args,{signal,gitDir,env={},input,maxOutput=4*1024*1024,allowed=[],recordWorker,recordChild,commandId,timeoutMs=30000}={},observed=false){
|
|
40
|
+
const originalDeadline=observed?performance.now()+timeoutMs:null;
|
|
41
|
+
if(!['init','hash-object','update-index','write-tree','commit-tree','update-ref','worktree','read-tree','merge-tree'].includes(args[0])||args[0]==='merge-tree'&&!gitDir||args[0]==='hash-object'&&(!args.includes('--no-filters')||!args.includes('--stdin')))throw gitFailure('This Git command is not on the bounded native mutation surface.','GIT_COMMAND_SCOPE');
|
|
42
|
+
if(typeof recordWorker!=='function')throw gitFailure('A durable worker gate is required.');
|
|
43
|
+
if(observed){
|
|
44
|
+
assert(Number.isFinite(timeoutMs)&&timeoutMs>0&&timeoutMs<=30000);
|
|
45
|
+
if(process.platform!=='linux')throw gitFailure('Native child witness is Linux-only.','GIT_CHILD_PLATFORM');
|
|
46
|
+
assertGitCommandId(commandId);if(typeof recordChild!=='function')throw gitFailure('A durable child recorder is required.','GIT_CHILD_RECORDER_REQUIRED');
|
|
47
|
+
args=structuredClone(args);env=structuredClone(env);allowed=structuredClone(allowed);
|
|
48
|
+
if(input!==undefined){assert(Buffer.isBuffer(input)||input instanceof Uint8Array);input=Buffer.from(input);}
|
|
49
|
+
}
|
|
50
|
+
signal?.throwIfAborted();
|
|
51
|
+
const nativeArgs=observed?[...baseArgs,...gitDir?['--git-dir='+gitDir]:[],...args]:null;
|
|
52
|
+
const gate=observed&&gitCommandNeedsInputGate(args),inputHash=observed?shaInput(input):null,nativeHash=observed?shaInput(Buffer.from(JSON.stringify(nativeArgs))):null;
|
|
53
|
+
const expectedExe=gate?await selectedGitExecutable(gitHostEnvironment(env),cwd):null;
|
|
54
|
+
signal?.throwIfAborted();if(observed&&performance.now()>=originalDeadline)throw gitFailure('Original Git command deadline expired before worker spawn.','GIT_COMMAND_INTERRUPTED');
|
|
55
|
+
const child=spawn(process.execPath,[fileURLToPath(new URL('./git-process-worker.mjs',import.meta.url))],{cwd,detached:process.platform!=='win32',stdio:['ignore','ignore','pipe','ipc'],serialization:'advanced',windowsHide:true});
|
|
56
|
+
let result,workerFact,readySeen=false,spawnSeen=false,released=false,closed=false,cancelled=false,dispatched=false,killTimer,stderr='',gateError,gating=Promise.resolve();child.stderr.on('data',b=>{if(stderr.length<8192)stderr+=b;});
|
|
57
|
+
const abort=()=>{if(observed&&cancelled)return;cancelled=true;if(child.connected)child.send(observed?{format:GIT_INPUT_GATE_PROTOCOL,type:'cancel',command_id:commandId}:{type:'cancel'},()=>{});else child.kill('SIGTERM');killTimer=setTimeout(()=>{if(!closed){try{process.platform==='win32'?child.kill('SIGKILL'):process.kill(-child.pid,'SIGKILL');}catch{}}},500);};
|
|
58
|
+
const timer=setTimeout(abort,observed?Math.max(0,originalDeadline-performance.now()):timeoutMs);signal?.addEventListener('abort',abort,{once:true});
|
|
59
|
+
try{
|
|
60
|
+
return await new Promise((resolve,reject)=>{
|
|
61
|
+
child.on('error',error=>{gateError=error;});
|
|
62
|
+
child.on('message',message=>{
|
|
63
|
+
if(message.type==='ready'){
|
|
64
|
+
if(observed&&readySeen){gateError??=gitFailure('Duplicate native worker readiness.','GIT_COMMAND_PROTOCOL');abort();return;}readySeen=true;
|
|
65
|
+
if(observed&&!message.protocols?.includes(GIT_INPUT_GATE_PROTOCOL)){gateError=gitFailure('Native worker protocol is unavailable.','GIT_COMMAND_PROTOCOL');abort();return;}
|
|
66
|
+
gating=(async()=>{try{
|
|
67
|
+
if(cancelled||signal?.aborted){abort();return;}
|
|
68
|
+
// This await is the command's execution frontier. A failed recorder must
|
|
69
|
+
// never release the IPC gate, even if the worker itself is alive.
|
|
70
|
+
if(observed){assert.equal(message.identity?.pid,child.pid);assert.equal(message.process_group,child.pid);for(const k of ['host','boot','namespace'])assert.equal(message.identity[k],processIdentity[k]);}
|
|
71
|
+
workerFact=Object.freeze({identity:Object.freeze({...message.identity}),process_group:message.process_group});
|
|
72
|
+
if(observed)await recordWorker(structuredClone(workerFact),Object.freeze({format:GIT_INPUT_GATE_PROTOCOL,command_id:commandId,input_sha256:inputHash,native_args:Object.freeze([...nativeArgs]),input_gate:gate}));
|
|
73
|
+
else await recordWorker({identity:message.identity,process_group:message.process_group});
|
|
74
|
+
signal?.throwIfAborted();if(observed&&performance.now()>=originalDeadline){abort();return;}if(cancelled){abort();return;}
|
|
75
|
+
dispatched=true;child.send(observed?{format:GIT_INPUT_GATE_PROTOCOL,type:'execute',command_id:commandId,cwd,args:nativeArgs,env:gitHostEnvironment(env),input,maxOutput}:{type:'execute',cwd,args:[...baseArgs,...gitDir?['--git-dir='+gitDir]:[],...args],env:gitHostEnvironment(env),input,maxOutput},error=>{if(error){gateError=error;abort();}});
|
|
76
|
+
}catch(error){gateError=error;abort();}})();
|
|
77
|
+
}else if(observed&&message.type==='spawned'){
|
|
78
|
+
if(spawnSeen){gateError=gitFailure('Duplicate child spawn observation.','GIT_COMMAND_PROTOCOL');abort();return;}spawnSeen=true;
|
|
79
|
+
gating=gating.then(async()=>{try{
|
|
80
|
+
signal?.throwIfAborted();if(cancelled)return;assert(gate&&dispatched);assertProtocolMessage(message,'spawned',commandId,'child_pid');
|
|
81
|
+
const before=await gitChildIdentity(message.child_pid,workerFact,nativeArgs,expectedExe);
|
|
82
|
+
const witness={format:GIT_INPUT_GATE_PROTOCOL,command_id:commandId,worker:structuredClone(workerFact),child:before,input_sha256:inputHash,args_sha256:nativeHash};
|
|
83
|
+
assertGitChildWitness(witness,{command_id:commandId,worker:workerFact,input_sha256:inputHash,native_args:nativeArgs});
|
|
84
|
+
signal?.throwIfAborted();if(performance.now()>=originalDeadline){abort();return;}if(cancelled)return;
|
|
85
|
+
await recordChild(structuredClone(witness)); // Durable join plus current host frame, never model callback.
|
|
86
|
+
signal?.throwIfAborted();if(performance.now()>=originalDeadline){abort();return;}if(cancelled)return;
|
|
87
|
+
assert.deepEqual(await gitChildIdentity(message.child_pid,workerFact,nativeArgs,expectedExe),before,'Native Git child identity changed before stdin release');
|
|
88
|
+
signal?.throwIfAborted();if(performance.now()>=originalDeadline){abort();return;}if(cancelled)return;
|
|
89
|
+
released=true;child.send({format:GIT_INPUT_GATE_PROTOCOL,type:'release_stdin',command_id:commandId},error=>{if(error){gateError=error;abort();}});
|
|
90
|
+
}catch(error){gateError??=error;abort();}});
|
|
91
|
+
}else if(message.type==='result'){
|
|
92
|
+
try{if(observed){assertProtocolMessage(message,'result',commandId,'code,signal,overflow,spawnError,stdout,stderr,input_released');assert(!result,'Duplicate native result');if(gate&&message.code===0&&!message.spawnError&&!message.signal)assert(released&&message.input_released===true,'Successful input command has no original input release');}result=message;}
|
|
93
|
+
catch(error){gateError??=error;abort();}
|
|
94
|
+
}else if(observed){gateError??=gitFailure('Unknown native worker message.','GIT_COMMAND_PROTOCOL');abort();}
|
|
95
|
+
});
|
|
96
|
+
child.on('close',async()=>{
|
|
97
|
+
closed=true;
|
|
98
|
+
await gating;
|
|
99
|
+
if(gateError){reject(observed&&signal?.aborted?signal.reason:gateError);return;}
|
|
100
|
+
if(observed&&performance.now()>=originalDeadline)cancelled=true;
|
|
101
|
+
if(cancelled||signal?.aborted){reject(signal?.reason||gitFailure('Git command was cancelled or exceeded its deadline.','GIT_COMMAND_INTERRUPTED'));return;}
|
|
102
|
+
if(!result||result.spawnError||result.overflow||result.signal||result.code!==0&&!allowed.includes(result.code)){
|
|
103
|
+
const failure=gitFailure('Git command failed; inspect its prepared resource before proposing another effect. '+(result?.overflow?'Output bound exceeded.':result?.spawnError||result?.stderr?.toString('utf8').slice(0,1000)||stderr.slice(0,300)),'GIT_COMMAND_FAILED');
|
|
104
|
+
if(result&&!result.spawnError&&!result.overflow&&!result.signal&&Number.isInteger(result.code))failure.git_exit={code:result.code,stdout:result.stdout};reject(failure);return;
|
|
105
|
+
}
|
|
106
|
+
resolve({code:result.code,stdout:result.stdout});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}catch(error){if(!dispatched&&closed)error.git_dispatch='not_released';throw error;}
|
|
110
|
+
finally{clearTimeout(timer);clearTimeout(killTimer);signal?.removeEventListener('abort',abort);}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const GIT_INPUT_GATE_PROTOCOL='comal-git-command-input-gate/2';
|
|
114
|
+
const shaInput=b=>createHash('sha256').update(b??Buffer.alloc(0)).digest('hex');
|
|
115
|
+
export function assertGitCommandId(id){assert.match(id,/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/);}
|
|
116
|
+
export function gitCommandNeedsInputGate(args){
|
|
117
|
+
assert(Array.isArray(args)&&args.every(x=>typeof x==='string'&&!x.includes('\0')));
|
|
118
|
+
return args[0]==='hash-object'&&args.includes('--stdin')||args[0]==='update-index'&&args.includes('--index-info')||args[0]==='commit-tree'&&!args.some(x=>x==='-m'||x==='-F'||x.startsWith('-m')||x.startsWith('-F'));
|
|
119
|
+
}
|
|
120
|
+
export function gitWorkerInputGate(args){assert.deepEqual(args.slice(0,baseArgs.length),baseArgs);let rest=args.slice(baseArgs.length);if(rest[0]?.startsWith('--git-dir='))rest=rest.slice(1);return gitCommandNeedsInputGate(rest);}
|
|
121
|
+
export function assertProtocolMessage(message,type,id,extra=''){
|
|
122
|
+
assert(message&&typeof message==='object'&&!Array.isArray(message));assert.deepEqual(Object.keys(message).sort(),['format','type','command_id',...(extra?extra.split(','):[])].sort());
|
|
123
|
+
assert.equal(message.format,GIT_INPUT_GATE_PROTOCOL);assert.equal(message.type,type);assert.equal(message.command_id,id);assertGitCommandId(id);
|
|
124
|
+
}
|
|
125
|
+
// Grammar/lifecycle state only. It cannot spawn/select work or certify a PID.
|
|
126
|
+
export function createGitInputReleaseState(id){
|
|
127
|
+
assertGitCommandId(id);let phase='awaiting_spawn';return Object.freeze({
|
|
128
|
+
spawned(pid){assert.equal(phase,'awaiting_spawn');assert(Number.isSafeInteger(pid)&&pid>1);phase='awaiting_release';},
|
|
129
|
+
release(message){assertProtocolMessage(message,'release_stdin',id);assert.equal(phase,'awaiting_release');phase='released';},
|
|
130
|
+
cancel(message){assertProtocolMessage(message,'cancel',id);assert.notEqual(phase,'cancelled');phase='cancelled';},
|
|
131
|
+
released(){return phase==='released';}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
export function assertGitChildWitness(value,prepared){
|
|
135
|
+
assert.deepEqual(Object.keys(value).sort(),['args_sha256','child','command_id','format','input_sha256','worker']);
|
|
136
|
+
assert.equal(value.format,GIT_INPUT_GATE_PROTOCOL);assertGitCommandId(value.command_id);assert.equal(value.command_id,prepared.command_id);assert.deepEqual(value.worker,prepared.worker);
|
|
137
|
+
assert.equal(value.input_sha256,prepared.input_sha256);assert.equal(value.args_sha256,shaInput(Buffer.from(JSON.stringify(prepared.native_args))));
|
|
138
|
+
const c=value.child,w=prepared.worker;assert.deepEqual(Object.keys(c).sort(),['argv','boot','executable','host','namespace','pid','ppid','process_group','start']);
|
|
139
|
+
assert(Number.isSafeInteger(c.pid)&&c.pid>1&&c.pid!==w.identity.pid);assert.equal(c.ppid,w.identity.pid);assert.equal(c.process_group,w.process_group);assert.equal(w.process_group,w.identity.pid);
|
|
140
|
+
for(const k of ['host','boot','namespace']){assert.equal(typeof c[k],'string');assert(c[k].length>0);assert.equal(c[k],w.identity[k]);}
|
|
141
|
+
assert.match(c.start,/^[0-9]+$/);assert(typeof c.executable==='string'&&path.isAbsolute(c.executable)&&path.resolve(c.executable)===c.executable);assert.deepEqual(c.argv.slice(1),prepared.native_args);assert.equal(c.argv[0],'git');
|
|
142
|
+
}
|
|
143
|
+
export function assertGitPreparedInputCommand(row){
|
|
144
|
+
assert.equal(row.dispatch_protocol,GIT_INPUT_GATE_PROTOCOL);assertGitCommandId(row.command_id);
|
|
145
|
+
assert.match(row.input_sha256,/^[a-f0-9]{64}$/);assert.deepEqual(row.native_args.slice(0,baseArgs.length),baseArgs);
|
|
146
|
+
let args=row.native_args.slice(baseArgs.length);if(args[0]?.startsWith('--git-dir='))args=args.slice(1);
|
|
147
|
+
assert.deepEqual(args,row.args);assert.equal(row.input_gate,gitCommandNeedsInputGate(row.args));
|
|
148
|
+
assert.deepEqual(Object.keys(row.worker).sort(),['identity','process_group']);assert.deepEqual(Object.keys(row.worker.identity).sort(),['boot','host','namespace','pid','start','version']);assert.equal(row.worker.identity.version,1);
|
|
149
|
+
for(const k of ['host','boot','namespace'])assert(typeof row.worker.identity[k]==='string'&&row.worker.identity[k].length>0);assert.match(row.worker.identity.start,/^[0-9]+$/);
|
|
150
|
+
assert.equal(row.worker.process_group,row.worker.identity.pid);assert(Number.isSafeInteger(row.worker.identity.pid)&&row.worker.identity.pid>1);
|
|
151
|
+
}
|
|
152
|
+
// Structural law for new rows only; load() separately authenticates its full
|
|
153
|
+
// journal/resource/owner. Pure return is not permission or native attestation.
|
|
154
|
+
export function assertGitChildJournal(rows){
|
|
155
|
+
const native=new Map(),spawned=new Set(),ended=new Set(),actions=new Set();
|
|
156
|
+
const keys=(row,names)=>assert.deepEqual(Object.keys(row).sort(),[...names,...(Object.hasOwn(row,'recovered_append_lock')?['recovered_append_lock']:[])].sort());
|
|
157
|
+
for(const row of rows){
|
|
158
|
+
if(row.type==='git_action_prepared')actions.add(JSON.stringify([row.action,row.attempt]));
|
|
159
|
+
if(row.type==='git_command_prepared'&&row.dispatch_protocol!==undefined){
|
|
160
|
+
keys(row,['type','action','attempt','command_id','args','input_sha256','worker','dispatch_protocol','native_args','input_gate','source','mechanism','timestamp','audit']);
|
|
161
|
+
assert(actions.has(JSON.stringify([row.action,row.attempt])));assertGitPreparedInputCommand(row);assert(!native.has(row.command_id));native.set(row.command_id,row);
|
|
162
|
+
}else if(row.type==='git_command_spawned'){
|
|
163
|
+
keys(row,['type','action','attempt','command_id','prepared_sha256','witness','source','mechanism','timestamp','audit']);
|
|
164
|
+
const p=native.get(row.command_id);assert(p&&p.input_gate&&!spawned.has(row.command_id)&&!ended.has(row.command_id),'Unbound, duplicate or late spawned child');
|
|
165
|
+
assert.equal(row.action,p.action);assert.equal(row.attempt,p.attempt);assert.equal(row.prepared_sha256,p.audit.sha256);assertGitChildWitness(row.witness,p);spawned.add(row.command_id);
|
|
166
|
+
}else if(row.type==='git_command_returned'&&native.has(row.command_id)){
|
|
167
|
+
const p=native.get(row.command_id);assert(!ended.has(row.command_id));assert.equal(row.action,p.action);assert.equal(row.attempt,p.attempt);
|
|
168
|
+
const extras=['output_sha256','dispatch','scope'].filter(k=>Object.hasOwn(row,k));keys(row,['type','action','attempt','command_id','code','source','mechanism','timestamp','audit',...extras]);
|
|
169
|
+
if(row.dispatch!==undefined){assert.equal(row.dispatch,'not_released');assert.equal(row.scope,'no_git_process_started');assert.equal(row.code,null);assert(!Object.hasOwn(row,'output_sha256'));assert(!spawned.has(row.command_id),'Spawned command falsely says not released');}
|
|
170
|
+
else{assert(Number.isInteger(row.code));assert.match(row.output_sha256,/^[a-f0-9]{64}$/);if(row.scope!==undefined)assert.equal(row.scope,'native_git_exit_observed');if(p.input_gate)assert(spawned.has(row.command_id),'Input result lacks durable spawn');}
|
|
171
|
+
ended.add(row.command_id);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function selectedGitExecutable(env,cwd){
|
|
176
|
+
for(const entry of(env.PATH??'').split(path.delimiter)){
|
|
177
|
+
const candidate=path.resolve(cwd,entry||'.','git');try{await access(candidate,constants.X_OK);const file=await realpath(candidate);assert((await lstat(file)).isFile());return file;}catch(e){if(['ENOENT','ENOTDIR','EACCES'].includes(e.code))continue;throw e;}
|
|
178
|
+
}
|
|
179
|
+
throw gitFailure('Actual native Git executable is unavailable.','GIT_COMMAND_PROTOCOL');
|
|
180
|
+
}
|
|
181
|
+
async function gitChildIdentity(pid,worker,args,expectedExe){
|
|
182
|
+
assert(Number.isSafeInteger(pid)&&pid>1);const base='/proc/'+pid,raw=await readFile(base+'/stat','utf8'),f=raw.slice(raw.lastIndexOf(')')+2).trim().split(/\s+/);
|
|
183
|
+
assert(!['Z','X','x'].includes(f[0]),'Native Git child is not live');assert.equal(Number(f[1]),worker.identity.pid);assert.equal(Number(f[2]),worker.process_group);
|
|
184
|
+
const executable=await realpath(base+'/exe');assert.equal(executable,expectedExe);const a=await stat(base+'/exe'),b=await stat(expectedExe);assert.equal(a.dev,b.dev);assert.equal(a.ino,b.ino);
|
|
185
|
+
const argv=(await readFile(base+'/cmdline')).toString().split('\0');assert.equal(argv.pop(),'');assert.equal(argv[0],'git');assert.deepEqual(argv.slice(1),args);
|
|
186
|
+
const identity={pid,ppid:Number(f[1]),process_group:Number(f[2]),start:f[19],host:processIdentity.host,boot:(await readFile('/proc/sys/kernel/random/boot_id','utf8')).trim(),namespace:await readlink(base+'/ns/pid'),executable,argv};
|
|
187
|
+
for(const k of ['host','boot','namespace'])assert.equal(identity[k],worker.identity[k]);return identity;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Reads repository objects through a house-owned bare repository. Project merge
|
|
191
|
+
// drivers, filters, includes and signing programs are never its configuration.
|
|
192
|
+
export class GitPlumbing {
|
|
193
|
+
constructor({cwd,directory,repository,mutate}){Object.assign(this,{cwd,directory,repository,mutate});}
|
|
194
|
+
async prepare(signal){
|
|
195
|
+
const common=(await readGit(this.cwd,['rev-parse','--path-format=absolute','--git-common-dir'],{signal})).stdout.toString().replace(/\n$/,'');
|
|
196
|
+
if(await realpath(common)!==this.repository.common_directory)throw gitFailure('The repository identity changed.','GIT_REPOSITORY_CHANGED');
|
|
197
|
+
await privateGitDirectory(this.directory);this.bare=path.join(this.directory,'plumbing.git');this.empty=path.join(this.directory,'empty-template');await privateGitDirectory(this.empty);
|
|
198
|
+
try{const info=await lstat(this.bare);if(!info.isDirectory()||info.isSymbolicLink())throw gitFailure('Plumbing metadata is not an owned directory.');}
|
|
199
|
+
catch(error){if(error.code!=='ENOENT')throw error;await this.mutate(['init','--bare','--quiet','--object-format='+this.repository.object_format,'--template='+this.empty,this.bare],{signal});}
|
|
200
|
+
const config=await readFile(path.join(this.bare,'config'),'utf8');
|
|
201
|
+
// Only Git init's closed baseline is admitted. In particular no include,
|
|
202
|
+
// driver, command, worktree, proxy, extension or credential is inherited.
|
|
203
|
+
const baseline=this.repository.object_format==='sha256'?'\tobjectformat = sha256':'';
|
|
204
|
+
for(const line of config.trim().split(/\r?\n/))if(!/^\[(core|extensions)\]$/.test(line)&&!/^\s*(repositoryformatversion = [01]|filemode = (true|false)|bare = true)\s*$/.test(line)&&!(process.platform==='win32'&&/^\s*(symlinks = false|ignorecase = true)\s*$/.test(line))&&line!==baseline)throw gitFailure('Plumbing configuration changed; no project code was executed.');
|
|
205
|
+
const format=(await readGit(this.cwd,['rev-parse','--show-object-format'],{signal,gitDir:this.bare})).stdout.toString().trim();if(format!==this.repository.object_format)throw gitFailure('Plumbing object format differs.');
|
|
206
|
+
this.objectDirectory=path.resolve((await readGit(this.cwd,['rev-parse','--path-format=absolute','--git-path','objects'],{signal})).stdout.toString().replace(/\n$/,''));
|
|
207
|
+
if(await realpath(this.objectDirectory)!==this.objectDirectory)throw gitFailure('Git object directory is noncanonical.');
|
|
208
|
+
}
|
|
209
|
+
options(options={}){return{...options,gitDir:this.bare,env:{GIT_OBJECT_DIRECTORY:this.objectDirectory,...options.env}};}
|
|
210
|
+
read(args,options){return readGit(this.cwd,args,this.options(options));}
|
|
211
|
+
write(args,options){return this.mutate(args,this.options(options));}
|
|
212
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Host-only v1 worker plus fixed Linux v2 original-input gate. No shell/loader.
|
|
2
|
+
import {spawn} from 'node:child_process';
|
|
3
|
+
import {processIdentity} from './process-lock.mjs';
|
|
4
|
+
import {GIT_INPUT_GATE_PROTOCOL,assertProtocolMessage,gitWorkerInputGate,createGitInputReleaseState} from './git-plumbing.mjs';
|
|
5
|
+
let launched=false,child,stopping=false,protocol2=null,releaseState,pendingInput,inputWasReleased=false;
|
|
6
|
+
const stop=()=>{
|
|
7
|
+
if(stopping)return;stopping=true;
|
|
8
|
+
if(process.platform!=='win32'){
|
|
9
|
+
process.removeAllListeners('SIGTERM');
|
|
10
|
+
try{process.kill(-process.pid,'SIGTERM');}catch{process.exit(1);}
|
|
11
|
+
}else if(child){child.kill();}else process.exit(1);
|
|
12
|
+
};
|
|
13
|
+
process.on('disconnect',stop);process.on('SIGTERM',stop);
|
|
14
|
+
// One actual Git spawn/output executor for the two source-selected protocols.
|
|
15
|
+
function launch(message,observed){
|
|
16
|
+
const {cwd,args,env,input,maxOutput}=message,gate=observed&&gitWorkerInputGate(args);
|
|
17
|
+
child=spawn('git',args,{cwd,env,stdio:['pipe','pipe','pipe'],windowsHide:true});
|
|
18
|
+
const out=[],err=[];let length=0,errLength=0,overflow=false,spawnError;
|
|
19
|
+
child.on('error',error=>{spawnError=error.message;});
|
|
20
|
+
child.stdout.on('data',buffer=>{length+=buffer.length;if(length>maxOutput){overflow=true;child.kill();}else out.push(buffer);});
|
|
21
|
+
child.stderr.on('data',buffer=>{if(errLength<8192){err.push(buffer.subarray(0,8192-errLength));errLength+=buffer.length;}});
|
|
22
|
+
child.stdin.on('error',()=>{});
|
|
23
|
+
if(observed){pendingInput=input;child.once('spawn',()=>{try{
|
|
24
|
+
if(gate){releaseState.spawned(child.pid);process.send({format:GIT_INPUT_GATE_PROTOCOL,type:'spawned',command_id:protocol2,child_pid:child.pid},error=>{if(error)stop();});}
|
|
25
|
+
else{child.stdin.end(pendingInput||undefined);pendingInput=undefined;}
|
|
26
|
+
}catch{stop();}});}else child.stdin.end(input||undefined);
|
|
27
|
+
child.on('close',(code,signal)=>{
|
|
28
|
+
pendingInput=undefined;
|
|
29
|
+
if(!process.connected){process.exit(1);return;}
|
|
30
|
+
if(observed)process.send({format:GIT_INPUT_GATE_PROTOCOL,type:'result',command_id:protocol2,code,signal,overflow,spawnError,stdout:Buffer.concat(out),stderr:Buffer.concat(err),input_released:inputWasReleased},()=>{process.removeAllListeners('disconnect');process.disconnect();});
|
|
31
|
+
else process.send({type:'result',code,signal,overflow,spawnError,stdout:Buffer.concat(out),stderr:Buffer.concat(err)},()=>{process.removeAllListeners('disconnect');process.disconnect();});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function version2(message){
|
|
35
|
+
try{
|
|
36
|
+
if(message.type==='cancel'){assertProtocolMessage(message,'cancel',message.command_id);if(protocol2){if(message.command_id!==protocol2)throw Error('Stale cancel');releaseState.cancel(message);}stop();return;}
|
|
37
|
+
if(message.type==='release_stdin'){
|
|
38
|
+
if(!protocol2||message.command_id!==protocol2)throw Error('No matching spawned command');releaseState.release(message);inputWasReleased=true;child.stdin.end(pendingInput||undefined);pendingInput=undefined;return;
|
|
39
|
+
}
|
|
40
|
+
assertProtocolMessage(message,'execute',message.command_id,'cwd,args,env,input,maxOutput');
|
|
41
|
+
if(process.platform!=='linux'||launched)throw Error('Unavailable or repeated native protocol');launched=true;protocol2=message.command_id;releaseState=createGitInputReleaseState(protocol2);launch(message,true);
|
|
42
|
+
}catch{stop();}
|
|
43
|
+
}
|
|
44
|
+
process.on('message',message=>{
|
|
45
|
+
if(message?.format===GIT_INPUT_GATE_PROTOCOL){version2(message);return;}
|
|
46
|
+
if(protocol2){stop();return;}
|
|
47
|
+
if(message?.type==='cancel'){stop();return;}
|
|
48
|
+
if(message?.type!=='execute'||launched)return;launched=true;launch(message,false);
|
|
49
|
+
});
|
|
50
|
+
process.send({type:'ready',identity:processIdentity,process_group:process.platform==='win32'?null:process.pid,protocols:process.platform==='linux'?[GIT_INPUT_GATE_PROTOCOL]:[]});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {execFile} from 'node:child_process';
|
|
2
|
+
import {promisify} from 'node:util';
|
|
3
|
+
import {lstat,open,readlink,realpath} from 'node:fs/promises';
|
|
4
|
+
import {constants} from 'node:fs';
|
|
5
|
+
import {createHash} from 'node:crypto';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {digest,immutable} from './operations.mjs';
|
|
8
|
+
|
|
9
|
+
const exec=promisify(execFile),hash=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
const fail=(message,code='GIT_SOURCE_UNSUPPORTED')=>Object.assign(new Error(message),{code});
|
|
11
|
+
const protectedRoot=/^(?:\.git|\.comal|\.harness|\.analysis)(?:\/|$)/;
|
|
12
|
+
function text(bytes){const value=bytes.toString('utf8');if(!Buffer.from(value).equals(bytes))throw fail('Git source names require lossless UTF-8.');return value;}
|
|
13
|
+
const line=bytes=>text(bytes).replace(/\n$/,'');
|
|
14
|
+
const blobHash=(bytes,format)=>createHash(format).update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
15
|
+
function environment(){
|
|
16
|
+
const env=Object.fromEntries(Object.entries(process.env).filter(([key])=>!key.startsWith('GIT_')));
|
|
17
|
+
return{...env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null',GIT_TERMINAL_PROMPT:'0',GIT_OPTIONAL_LOCKS:'0',LC_ALL:'C'};
|
|
18
|
+
}
|
|
19
|
+
async function git(cwd,args,signal,allowed=[]){
|
|
20
|
+
try{return(await exec('git',['--no-pager','--no-optional-locks','--literal-pathspecs','-c','core.fsmonitor=false','-c','core.hooksPath=/dev/null','-c','status.submoduleSummary=false',...args],{cwd,env:environment(),signal,timeout:10000,maxBuffer:4*1024*1024,encoding:'buffer'})).stdout;}
|
|
21
|
+
catch(error){if(allowed.includes(error.code))return null;if(signal?.aborted)throw signal.reason;throw fail('Git inspection failed or exceeded its bound. No partial identity was issued.','GIT_INSPECT_FAILED');}
|
|
22
|
+
}
|
|
23
|
+
async function fileHash(file,budget,signal,format){
|
|
24
|
+
const before=await lstat(file,{bigint:true}).catch(error=>{if(error.code==='ENOENT')return null;throw error;});
|
|
25
|
+
if(!before)return{kind:'absent'};
|
|
26
|
+
if(before.isSymbolicLink()){const bytes=await readlink(file,{encoding:'buffer'});if(bytes.length>budget.remaining)throw fail('Git source exceeds the total byte bound.','GIT_SOURCE_BOUND');budget.remaining-=bytes.length;return{kind:'symlink',sha256:hash(bytes),...(format?{blob_oid:blobHash(bytes,format)}:{})};}
|
|
27
|
+
if(!before.isFile())throw fail('Git source contains a nonregular file.');
|
|
28
|
+
if(before.size>BigInt(budget.remaining))throw fail('Git source exceeds the total byte bound.','GIT_SOURCE_BOUND');
|
|
29
|
+
const handle=await open(file,constants.O_RDONLY|(constants.O_NOFOLLOW||0));
|
|
30
|
+
try{
|
|
31
|
+
const opened=await handle.stat({bigint:true});
|
|
32
|
+
if(opened.ino!==before.ino||opened.dev!==before.dev)throw fail('Source changed while opening.','GIT_SOURCE_CHANGED');
|
|
33
|
+
const sha=createHash('sha256'),blob=format?createHash(format).update(`blob ${before.size}\0`):null,buffer=Buffer.alloc(128*1024);let bytes=0;
|
|
34
|
+
for(;;){signal.throwIfAborted();const result=await handle.read(buffer,0,buffer.length,null);if(!result.bytesRead)break;bytes+=result.bytesRead;if(bytes>budget.remaining)throw fail('Git source exceeds the total byte bound.','GIT_SOURCE_BOUND');const chunk=buffer.subarray(0,result.bytesRead);sha.update(chunk);blob?.update(chunk);}
|
|
35
|
+
const after=await handle.stat({bigint:true});
|
|
36
|
+
if(before.size!==after.size||before.mtimeNs!==after.mtimeNs||before.ctimeNs!==after.ctimeNs||before.mode!==after.mode||BigInt(bytes)!==after.size)throw fail('Source changed during hashing.','GIT_SOURCE_CHANGED');
|
|
37
|
+
budget.remaining-=bytes;return{kind:'file',bytes,executable:Boolean(before.mode&0o111n),sha256:sha.digest('hex'),...(blob?{blob_oid:blob.digest('hex')}:{})};
|
|
38
|
+
}finally{await handle.close();}
|
|
39
|
+
}
|
|
40
|
+
async function metadata(cwd,signal){
|
|
41
|
+
const head=await git(cwd,['rev-parse','--verify','--quiet','HEAD'],signal,[1]),branch=await git(cwd,['symbolic-ref','--quiet','HEAD'],signal,[1]);
|
|
42
|
+
const indexPath=line(await git(cwd,['rev-parse','--path-format=absolute','--git-path','index'],signal));
|
|
43
|
+
const index=await fileHash(indexPath,{remaining:64*1024*1024},signal);
|
|
44
|
+
if(!['file','absent'].includes(index.kind))throw fail('Git index is not a bounded regular file.');
|
|
45
|
+
// status/diff of a working tree can execute project clean filters. Object and
|
|
46
|
+
// index listings do not refresh/convert source; raw blob hashes are computed here.
|
|
47
|
+
const committed=head?await git(cwd,['ls-tree','-r','-z','--full-tree','HEAD'],signal):Buffer.alloc(0);
|
|
48
|
+
const tracked=await git(cwd,['ls-files','--stage','-z'],signal),listed=await git(cwd,['ls-files','--others','--exclude-standard','-z'],signal);
|
|
49
|
+
// Host journals/worktrees are not source. Apply the same exclusion before
|
|
50
|
+
// stability comparison, so sibling allocation cannot look like source drift.
|
|
51
|
+
const names=text(listed).split('\0').filter(name=>name&&!protectedRoot.test(name));
|
|
52
|
+
const untracked=Buffer.from(names.length?names.join('\0')+'\0':'');
|
|
53
|
+
return{head:head?line(head):null,branch:branch?line(branch):null,index_sha256:index.sha256??null,tracked,untracked,committed};
|
|
54
|
+
}
|
|
55
|
+
const metadataStamp=m=>digest({...m,tracked:hash(m.tracked),untracked:hash(m.untracked),committed:hash(m.committed)});
|
|
56
|
+
async function observation(cwd,limits,signal){
|
|
57
|
+
const before=await metadata(cwd,signal),entries=text(before.tracked).split('\0').filter(Boolean).map(line=>{
|
|
58
|
+
const match=/^(\d{6}) ([a-f0-9]{40}|[a-f0-9]{64}) ([0-3])\t([\s\S]+)$/.exec(line);if(!match)throw fail('Unsupported Git index record.');
|
|
59
|
+
if(match[1]==='160000')throw fail('Submodule identity requires a separate resource contract.');
|
|
60
|
+
return{path:match[4],mode:match[1],oid:match[2],stage:Number(match[3])};
|
|
61
|
+
});
|
|
62
|
+
const others=text(before.untracked).split('\0').filter(Boolean).filter(file=>!protectedRoot.test(file));
|
|
63
|
+
if(entries.some(e=>protectedRoot.test(e.path)))throw fail('Tracked host state requires a separate resource contract.');
|
|
64
|
+
const names=[...new Set([...entries.map(e=>e.path),...others])].sort();
|
|
65
|
+
if(names.length>limits.maxFiles)throw fail('Git source exceeds the complete path bound.','GIT_SOURCE_BOUND');
|
|
66
|
+
const budget={remaining:limits.maxBytes},files=[];
|
|
67
|
+
for(const name of names){
|
|
68
|
+
signal.throwIfAborted();
|
|
69
|
+
if(path.isAbsolute(name)||name.split('/').some(part=>part==='..'||part==='')||name.includes('\0'))throw fail('Noncanonical Git source path.');
|
|
70
|
+
// Never follow a directory symlink outside the host-bound workspace.
|
|
71
|
+
const file=path.join(cwd,name);let ancestor=path.dirname(file),parent;
|
|
72
|
+
for(;;){try{parent=await realpath(ancestor);break;}catch(error){if(error.code!=='ENOENT'||ancestor===cwd)throw error;ancestor=path.dirname(ancestor);}}
|
|
73
|
+
const relative=path.relative(cwd,parent);if(relative==='..'||relative.startsWith('..'+path.sep)||path.isAbsolute(relative))throw fail('Git source parent escapes the workspace.');
|
|
74
|
+
files.push({path:name,...await fileHash(file,budget,signal,limits.objectFormat)});
|
|
75
|
+
}
|
|
76
|
+
const after=await metadata(cwd,signal);if(metadataStamp(before)!==metadataStamp(after))throw fail('Git metadata changed during source observation.','GIT_SOURCE_CHANGED');
|
|
77
|
+
const committed=text(before.committed).split('\0').filter(Boolean).map(record=>{
|
|
78
|
+
const match=/^(\d{6}) (blob|commit) ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(record);if(!match||match[1]==='160000')throw fail('Unsupported committed resource (submodule or tree record).');
|
|
79
|
+
return{path:match[4],mode:match[1],oid:match[3],stage:0};
|
|
80
|
+
});
|
|
81
|
+
const indexDiffers=digest(entries)!==digest(committed),byPath=new Map(files.map(file=>[file.path,file]));
|
|
82
|
+
const rawDiffers=entries.some(entry=>{
|
|
83
|
+
const file=byPath.get(entry.path);return entry.stage!==0||file?.blob_oid!==entry.oid||entry.mode==='120000'&&file.kind!=='symlink'||entry.mode!=='120000'&&(file.kind!=='file'||file.executable!==(entry.mode==='100755'));
|
|
84
|
+
});
|
|
85
|
+
return{head:before.head,branch:before.branch,index_sha256:before.index_sha256,dirty:indexDiffers||rawDiffers||others.length>0,index_differs_from_head:indexDiffers,raw_worktree_differs_from_index:rawDiffers,untracked_count:others.length,git_status:'not_run; raw bytes compared without project filters or newline conversion',index_entries:entries,files};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function inspectGitSource({cwd,signal,maxFiles=2000,maxBytes=64*1024*1024,timeoutMs=15000,afterObservation}={}){
|
|
89
|
+
const deadline=AbortSignal.timeout(timeoutMs),abort=signal?AbortSignal.any([signal,deadline]):deadline;
|
|
90
|
+
abort.throwIfAborted();const canonical=await realpath(cwd);
|
|
91
|
+
const root=await realpath(line(await git(canonical,['rev-parse','--show-toplevel'],abort)));
|
|
92
|
+
if(root!==canonical)throw fail('GitInspect requires the repository root as the Comal workspace; parent source was not inspected.');
|
|
93
|
+
const common=await realpath(line(await git(root,['rev-parse','--path-format=absolute','--git-common-dir'],abort))),format=line(await git(root,['rev-parse','--show-object-format'],abort));
|
|
94
|
+
if(!['sha1','sha256'].includes(format))throw fail('Unsupported Git object format.');
|
|
95
|
+
const limits={maxFiles,maxBytes,objectFormat:format},first=await observation(root,limits,abort);
|
|
96
|
+
await afterObservation?.(immutable(structuredClone(first)));abort.throwIfAborted();
|
|
97
|
+
const second=await observation(root,limits,abort);
|
|
98
|
+
if(digest(first)!==digest(second))throw fail('Git source did not remain stable across two complete observations.','GIT_SOURCE_CHANGED');
|
|
99
|
+
const repository={root,common_directory:common,object_format:format},identity=digest({repository,source:second});
|
|
100
|
+
return{repository,repository_id:digest({common_directory:common,object_format:format}),source_identity:identity,observation:'stable_double_read_not_atomic_snapshot',...second,limits,excluded:'ignored untracked files, untracked root host state, ignored dependencies and external resources'};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function registerGitSourceOperations(house){
|
|
104
|
+
if(house.registry.get('GitInspect'))return;
|
|
105
|
+
house.registry.add({name:'GitInspect',description:'Read the complete bounded Git source identity at the workspace root: HEAD, index and dirty/untracked bytes. Two stable observations; refuses drift, truncation and unsupported submodules. No shell, hooks, filters, fetch, commit or checkout. Identity is not verification.',input_schema:{type:'object',properties:{},additionalProperties:false},effects:{workspace:'read',external:'none',process:'read',authority:'none'},run:async(_,options)=>{
|
|
106
|
+
const identity=await inspectGitSource({cwd:options.workspace?.cwd||house.cwd,signal:options.signal});
|
|
107
|
+
options.observe({kind:'git_source_identity',...identity});
|
|
108
|
+
const{files,index_entries,...summary}=identity;return JSON.stringify({...summary,paths:files.length,index_entries:index_entries.length});
|
|
109
|
+
}});
|
|
110
|
+
}
|