mtech-ai-local-runner 0.1.73 → 0.1.74
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 +28 -0
- package/dist/src/adapters/claude-code/prompt-compiler.js +3 -1
- package/dist/src/adapters/codex/exec-invocation.js +24 -0
- package/dist/src/adapters/codex/exec-launch-host.js +5 -0
- package/dist/src/adapters/codex/exec-runtime-options.js +1 -1
- package/dist/src/adapters/codex/prompt-compiler.js +3 -1
- package/dist/src/asks/runtime.js +22 -1
- package/dist/src/daemon/execution-coordinator.js +65 -9
- package/dist/src/daemon/manual-codex.js +2 -2
- package/dist/src/daemon/runtime.js +20 -9
- package/dist/src/gateway-client.js +65 -5
- package/dist/src/protocol.js +33 -0
- package/dist/src/publication/server-ci-host.js +5 -6
- package/dist/src/rooms/activity.js +84 -0
- package/dist/src/rooms/notifier.js +21 -0
- package/dist/src/rooms/store.js +56 -0
- package/dist/src/safety/claude-managed-scope.js +7 -19
- package/dist/src/safety/claude-release.js +0 -2
- package/dist/src/safety/claude-review-evidence.js +2 -2
- package/dist/src/safety/codex-exec-production-host.js +15 -2
- package/dist/src/safety/job-tool-policy.js +4 -3
- package/dist/src/verification/continuation-runner-contract.js +228 -0
- package/dist/src/verification/p1-execution-host.js +25 -6
- package/dist/src/verification/p1-host-composition.js +2 -0
- package/dist/src/verification/reviewed-runner-contract.js +7 -0
- package/dist/src/verification/runner-observed-verifier.js +29 -5
- package/dist/src/workspace/registered-submodules.js +204 -0
- package/dist/src/workspace/workspace-manager.js +128 -18
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CONTINUATION_POLICY, hasContinuationHints, validateAttemptContext, attemptContextFingerprint, assertContinuationHost, assertMeasuredContinuationManifest, assertClaimedExecutionAttempt, registeredContinuationSubmodules } from './continuation-runner-contract.js';
|
|
1
2
|
import { RunnerObservedVerifier, assertGatewayVerificationScope } from './runner-observed-verifier.js';
|
|
2
3
|
import { isP1WorkingBranch } from '../publication/git-snapshot.js';
|
|
3
4
|
import { protocolHash } from '../publication/candidate.js';
|
|
@@ -6,24 +7,40 @@ export class RunnerObservedExecutionHost {
|
|
|
6
7
|
privateRoot;
|
|
7
8
|
supportedVerificationSchemas;
|
|
8
9
|
registration;
|
|
10
|
+
get registeredSubmodules() { return registeredContinuationSubmodules(this.registration); }
|
|
9
11
|
constructor(privateRoot, registration) {
|
|
10
12
|
this.privateRoot = privateRoot;
|
|
11
13
|
this.registration = structuredClone(registration);
|
|
12
14
|
this.supportedVerificationSchemas = Object.freeze([...(registration.supportedVerificationSchemas ?? ['local-runner-verification-v1'])]);
|
|
13
15
|
}
|
|
14
16
|
options(input) {
|
|
15
|
-
const p = input.job.taskPackage, r = p.repository;
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
const p = input.job.taskPackage, r = p.repository, resolved = resolveRunnerVerification(p.verification);
|
|
18
|
+
const attempt = resolved.version === 3 ? validateAttemptContext(input.executionAttempt) : undefined;
|
|
19
|
+
if (attempt && attemptContextFingerprint(attempt) !== input.job.executionAttemptFingerprint)
|
|
20
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
21
|
+
if (attempt) {
|
|
22
|
+
assertContinuationHost(attempt, this.registration);
|
|
23
|
+
if (attempt.job_id !== input.job.id || attempt.generation !== input.job.generation || attempt.contract_fingerprint !== p.contract_fingerprint || attempt.contract_base_commit_sha !== r.base_commit_sha || attempt.repository_key !== r.key)
|
|
24
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
25
|
+
}
|
|
26
|
+
return { ...(attempt ? { attemptContext: attempt, attemptContextFingerprint: attemptContextFingerprint(attempt) } : {}), root: input.checkoutPath, privateRoot: this.privateRoot, registration: this.registration, verification: resolved.runnerBinding,
|
|
27
|
+
fence: { binding_id: input.bindingId, job_id: input.job.id, generation: input.job.generation, contract_fingerprint: p.contract_fingerprint, base_commit_sha: attempt?.attempt_base_commit_sha ?? r.base_commit_sha, working_branch: attempt?.attempt_working_branch ?? r.working_branch },
|
|
18
28
|
allowedPaths: p.allowed_paths, blockedPaths: r.blocked_paths, candidateLimits: p.candidate_limits,
|
|
19
29
|
current: input.current, freezeOwnedModel: input.freezeOwnedModel, assertModelFrozen: input.assertModelFrozen, signal: input.signal, exportCandidate: input.exportCandidate };
|
|
20
30
|
}
|
|
21
31
|
assertEligible(job) {
|
|
32
|
+
const attempt = resolveRunnerVerification(job.taskPackage.verification).version === 3 ? assertClaimedExecutionAttempt(job) : undefined;
|
|
22
33
|
assertVerificationHostVersion(job.taskPackage.verification, this.supportedVerificationSchemas);
|
|
23
34
|
const r = this.registration;
|
|
24
35
|
if (r.manifest.schema !== 'local-project-verification-v1' || !r.review.receiptId?.trim() || !r.review.reviewer?.trim() || r.review.manifestFingerprint !== protocolHash(r.manifest))
|
|
25
36
|
throw Error('P1_REGISTRATION_REJECTED');
|
|
26
|
-
new RunnerObservedVerifier(this.options({ job, checkoutPath: '', bindingId: 'preflight', signal: new AbortController().signal, current: async () => false, freezeOwnedModel: async () => { throw Error('P1_NOT_FROZEN'); }, assertModelFrozen: async () => { throw Error('P1_NOT_FROZEN'); } })).assertContract();
|
|
37
|
+
new RunnerObservedVerifier(this.options({ ...(attempt ? { executionAttempt: attempt } : {}), job, checkoutPath: '', bindingId: 'preflight', signal: new AbortController().signal, current: async () => false, freezeOwnedModel: async () => { throw Error('P1_NOT_FROZEN'); }, assertModelFrozen: async () => { throw Error('P1_NOT_FROZEN'); } })).assertContract();
|
|
38
|
+
}
|
|
39
|
+
async assertCurrent(job) {
|
|
40
|
+
this.assertEligible(job);
|
|
41
|
+
const attempt = assertClaimedExecutionAttempt(job);
|
|
42
|
+
if (attempt)
|
|
43
|
+
await assertMeasuredContinuationManifest(attempt, this.registration);
|
|
27
44
|
}
|
|
28
45
|
verify(input) { assertVerificationHostVersion(input.job.taskPackage.verification, this.supportedVerificationSchemas); return new RunnerObservedVerifier(this.options(input)).verify(); }
|
|
29
46
|
}
|
|
@@ -37,7 +54,9 @@ export const hasP1 = (p) => {
|
|
|
37
54
|
/** Compare host launch claims to authoritative WEC/bundle fields before start. */
|
|
38
55
|
export function assertP1Bindings(p, canonical, bundle = false) {
|
|
39
56
|
const envelope = record(p.verification), resolved = resolveRunnerVerification(envelope), v = resolved.runnerBinding, r = record(p.repository), limits = record(p.candidate_limits);
|
|
40
|
-
if (resolved.version
|
|
57
|
+
if (resolved.version !== 3 && hasContinuationHints(p))
|
|
58
|
+
throw Error('P1_CONTRACT_REJECTED');
|
|
59
|
+
if (resolved.version === 2 || resolved.version === 3) {
|
|
41
60
|
const review = record(resolved.completionBinding.review_requirement), profiles = review.permitted_profiles;
|
|
42
61
|
if (typeof p.model_profile !== 'string' || !p.model_profile || !profiles.some(profile => profile.runtime_adapter !== p.runtime_adapter && profile.model_profile !== p.model_profile))
|
|
43
62
|
throw Error('P1_CONTRACT_REJECTED');
|
|
@@ -55,7 +74,7 @@ export function assertP1Bindings(p, canonical, bundle = false) {
|
|
|
55
74
|
throw Error('P1_CONTRACT_REJECTED');
|
|
56
75
|
if (!bundle) {
|
|
57
76
|
const policy = record(canonical.policy);
|
|
58
|
-
if (policy.policy_key !== (resolved.version === 2 ? REVIEWED_POLICY : 'local-runner-verified-v1') || policy.loop_type !== (resolved.version ===
|
|
77
|
+
if (policy.policy_key !== (resolved.version === 3 ? CONTINUATION_POLICY : resolved.version === 2 ? REVIEWED_POLICY : 'local-runner-verified-v1') || policy.loop_type !== (resolved.version === 1 ? 'agent_runtime_implementation' : REVIEWED_LOOP_TYPE))
|
|
59
78
|
throw Error('P1_CONTRACT_REJECTED');
|
|
60
79
|
}
|
|
61
80
|
const repos = canonical.repositories;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { registeredContinuationSubmodules } from './continuation-runner-contract.js';
|
|
2
3
|
import { chmod, mkdir, realpath } from 'node:fs/promises';
|
|
3
4
|
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
4
5
|
import { protocolHash } from '../publication/candidate.js';
|
|
@@ -15,6 +16,7 @@ export async function composeReviewedP1Host(input) {
|
|
|
15
16
|
return { status: 'mismatch' };
|
|
16
17
|
const registration = structuredClone(registrations[0]), profileId = registration.profileId, mismatch = { status: 'mismatch', profileId };
|
|
17
18
|
try {
|
|
19
|
+
registeredContinuationSubmodules(registration);
|
|
18
20
|
const node = await realpath(input.execPath ?? process.execPath), commands = registration.manifest.commands;
|
|
19
21
|
if (!commands.length || commands.some(command => command.executable !== node) || await realpath(registration.git.executable) !== registration.git.executable
|
|
20
22
|
|| registration.review.manifestFingerprint !== protocolHash(registration.manifest))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CONTINUATION_SCHEMA, CONTINUATION_HINT_KEYS, resolveContinuationVerification } from './continuation-runner-contract.js';
|
|
1
2
|
import { protocolHash, protocolJson } from '../publication/candidate.js';
|
|
2
3
|
import { assertGatewayVerificationScope } from './runner-observed-verifier.js';
|
|
3
4
|
export const REVIEWED_VERIFICATION_SCHEMA = 'local-runner-verification-v2';
|
|
@@ -26,7 +27,11 @@ export const REVIEWED_CRITERIA = {
|
|
|
26
27
|
/** Only unwraps the versioned envelope. The existing P1 host still validates every inner byte. */
|
|
27
28
|
export function resolveRunnerVerification(value) {
|
|
28
29
|
const envelope = object(value);
|
|
30
|
+
if (envelope.schema_version === CONTINUATION_SCHEMA)
|
|
31
|
+
return resolveContinuationVerification(envelope);
|
|
29
32
|
if (envelope.schema_version === 'local-runner-verification-v1') {
|
|
33
|
+
if (CONTINUATION_HINT_KEYS.some(key => Object.hasOwn(envelope, key)))
|
|
34
|
+
throw rejected();
|
|
30
35
|
if (envelope.mode !== 'runner_observed_v1' || envelope.policy_key !== 'local-runner-verified-v1')
|
|
31
36
|
throw rejected();
|
|
32
37
|
return { version: 1, runnerBinding: envelope, completionBinding: null };
|
|
@@ -72,6 +77,8 @@ export function resolveRunnerVerification(value) {
|
|
|
72
77
|
/** Future reviewed host implementations must explicitly advertise V2 support. */
|
|
73
78
|
export function assertVerificationHostVersion(value, supportedSchemas) {
|
|
74
79
|
const resolved = resolveRunnerVerification(value);
|
|
80
|
+
if (resolved.version === 3 && !supportedSchemas?.includes(CONTINUATION_SCHEMA))
|
|
81
|
+
throw Error('CORRECTION_HOST_INCOMPATIBLE');
|
|
75
82
|
if (resolved.version === 2 && !supportedSchemas?.includes(REVIEWED_VERIFICATION_SCHEMA))
|
|
76
83
|
throw Error('REVIEWED_VERIFICATION_HOST_REQUIRED');
|
|
77
84
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { validateAttemptEvidence, validateAttemptHandoff } from '../protocol.js';
|
|
2
|
+
import { validateAttemptContext, attemptContextFingerprint, assertContinuationHost, assertMeasuredContinuationManifest } from './continuation-runner-contract.js';
|
|
1
3
|
import { createHash } from 'node:crypto';
|
|
2
4
|
import { realpath } from 'node:fs/promises';
|
|
3
5
|
import path from 'node:path';
|
|
@@ -61,6 +63,14 @@ export class RunnerObservedVerifier {
|
|
|
61
63
|
} }
|
|
62
64
|
validate() {
|
|
63
65
|
const o = this.options, v = o.verification, p = v.profile_definition, r = o.registration, f = o.fence;
|
|
66
|
+
if ((o.attemptContext === undefined) !== (o.attemptContextFingerprint === undefined))
|
|
67
|
+
throw denied();
|
|
68
|
+
if (o.attemptContext) {
|
|
69
|
+
const c = validateAttemptContext(o.attemptContext);
|
|
70
|
+
assertContinuationHost(c, r);
|
|
71
|
+
if (o.attemptContextFingerprint !== attemptContextFingerprint(c) || f.job_id !== c.job_id || f.generation !== c.generation || f.contract_fingerprint !== c.contract_fingerprint || f.base_commit_sha !== c.attempt_base_commit_sha || f.working_branch !== c.attempt_working_branch)
|
|
72
|
+
throw denied();
|
|
73
|
+
}
|
|
64
74
|
keys(v, ['schema_version', 'mode', 'policy_key', 'profile_id', 'profile_fingerprint', 'project_id', 'runtime_adapter', 'runtime_transport', 'profile_definition', 'frozen_criteria_fingerprint', 'frozen_loop_contract', 'commands', 'command_manifest', 'bounds', 'allowed_paths', 'required_commands', 'qa_scenario_ids', 'required_criteria', 'independent_review_required', 'gateway_verification']);
|
|
65
75
|
if (v.gateway_verification !== undefined)
|
|
66
76
|
assertGatewayVerificationScope(v.gateway_verification);
|
|
@@ -104,6 +114,8 @@ export class RunnerObservedVerifier {
|
|
|
104
114
|
if (this.options.signal?.aborted || !await this.options.current() || this.options.signal?.aborted)
|
|
105
115
|
throw denied();
|
|
106
116
|
await this.options.assertModelFrozen();
|
|
117
|
+
if (this.options.attemptContext)
|
|
118
|
+
await assertMeasuredContinuationManifest(this.options.attemptContext, this.options.registration);
|
|
107
119
|
if (this.options.signal?.aborted)
|
|
108
120
|
throw denied();
|
|
109
121
|
}
|
|
@@ -126,8 +138,15 @@ export class RunnerObservedVerifier {
|
|
|
126
138
|
const candidate = await host.verificationCandidate(sealed);
|
|
127
139
|
if (!hex(candidate.commitSha, 40))
|
|
128
140
|
throw denied();
|
|
129
|
-
|
|
130
|
-
|
|
141
|
+
let changed_paths = buildP1ChangedPaths({ candidate_commit_sha: candidate.commitSha, base_commit_sha: f.base_commit_sha, entries: observation.changed_files }, v.bounds);
|
|
142
|
+
if (o.attemptContext) {
|
|
143
|
+
const { manifest_sha256: _old, ...unsigned } = changed_paths;
|
|
144
|
+
const next = { ...unsigned, schema_version: 'runner-changed-paths-v2', digest_domain: 'p1-changed-paths-v2', attempt_context_fingerprint: o.attemptContextFingerprint };
|
|
145
|
+
changed_paths = { ...next, manifest_sha256: protocolHash(next) };
|
|
146
|
+
if (Buffer.byteLength(protocolJson(changed_paths)) > v.bounds.max_manifest_bytes)
|
|
147
|
+
throw denied();
|
|
148
|
+
}
|
|
149
|
+
const snapshot_fingerprint = protocolHash({ digest_domain: o.attemptContext ? 'p1-snapshot-v2' : 'p1-snapshot-v1', fence: f, ...(o.attemptContext ? { attempt_context_fingerprint: o.attemptContextFingerprint } : {}), profile_fingerprint: v.profile_fingerprint, frozen_criteria_fingerprint: v.frozen_criteria_fingerprint, tree_fingerprint: sealed.treeFingerprint, diff_sha256: createHash('sha256').update(observation.diff).digest('hex'), changed_paths });
|
|
131
150
|
const registered = await buildReviewedProjectRegistration({ root: candidate.root, manifest: o.registration.manifest, review: o.registration.review, allowedPaths: o.allowedPaths, blockedPaths: o.blockedPaths });
|
|
132
151
|
const policy = await createIsolatedCommandPolicy({ root: candidate.root, commands: registered.commands, allowedPaths: o.allowedPaths, blockedPaths: o.blockedPaths }, true);
|
|
133
152
|
const receipts = [];
|
|
@@ -140,8 +159,8 @@ export class RunnerObservedVerifier {
|
|
|
140
159
|
const measurement = await policy.measureTest(command.id, { timeoutMs: v.bounds.max_command_runtime_ms, maxOutputBytes: v.bounds.max_output_bytes }, o.signal);
|
|
141
160
|
if (index < registered.commands.length - 1)
|
|
142
161
|
await check();
|
|
143
|
-
const identity = { binding_id: f.binding_id, job_id: f.job_id, generation: f.generation, contract_fingerprint: f.contract_fingerprint, profile_fingerprint: v.profile_fingerprint, frozen_criteria_fingerprint: v.frozen_criteria_fingerprint, candidate_commit_sha: candidate.commitSha, snapshot_fingerprint, command_id: command.id, command_fingerprint: protocolHash(command) };
|
|
144
|
-
receipts.push({ schema_version: 'runner-command-receipt-v1', receipt_id: protocolHash({ digest_domain: 'p1-command-receipt-id-v1', ...identity }), ...identity, observed_at: Math.floor(Date.now() / 1000), ...measurement });
|
|
162
|
+
const identity = { ...(o.attemptContext ? { attempt_context_fingerprint: o.attemptContextFingerprint } : {}), binding_id: f.binding_id, job_id: f.job_id, generation: f.generation, contract_fingerprint: f.contract_fingerprint, profile_fingerprint: v.profile_fingerprint, frozen_criteria_fingerprint: v.frozen_criteria_fingerprint, candidate_commit_sha: candidate.commitSha, snapshot_fingerprint, command_id: command.id, command_fingerprint: protocolHash(command) };
|
|
163
|
+
receipts.push({ schema_version: o.attemptContext ? 'runner-command-receipt-v2' : 'runner-command-receipt-v1', receipt_id: protocolHash({ digest_domain: o.attemptContext ? 'p1-command-receipt-id-v2' : 'p1-command-receipt-id-v1', ...identity }), ...identity, observed_at: Math.floor(Date.now() / 1000), ...measurement });
|
|
145
164
|
}
|
|
146
165
|
}
|
|
147
166
|
finally {
|
|
@@ -149,8 +168,13 @@ export class RunnerObservedVerifier {
|
|
|
149
168
|
}
|
|
150
169
|
await check();
|
|
151
170
|
const result = { candidate_commit_sha: candidate.commitSha, snapshot_fingerprint, changed_paths, receipts };
|
|
171
|
+
if (o.attemptContext)
|
|
172
|
+
validateAttemptEvidence(result, o.attemptContext);
|
|
152
173
|
if (o.exportCandidate && receipts.length > 0 && receipts.every(r => r.status === 'passed' && r.exit_code === 0)) {
|
|
153
|
-
|
|
174
|
+
const handoff = { schema_version: o.attemptContext ? 'gateway-candidate-handoff-v2' : 'gateway-candidate-handoff-v1', ...(o.attemptContext ? { attempt_context: structuredClone(o.attemptContext), attempt_context_fingerprint: o.attemptContextFingerprint } : {}), ...result, fence: f, profile_fingerprint: v.profile_fingerprint, frozen_criteria_fingerprint: v.frozen_criteria_fingerprint, tree_fingerprint: sealed.treeFingerprint, diff: observation.diff };
|
|
175
|
+
if (o.attemptContext)
|
|
176
|
+
validateAttemptHandoff(handoff, o.attemptContext);
|
|
177
|
+
await o.exportCandidate(structuredClone(handoff));
|
|
154
178
|
await check();
|
|
155
179
|
}
|
|
156
180
|
return result;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, mkdir, open, readdir, realpath, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
const CONFIG = '[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = true\n';
|
|
6
|
+
const LIMIT = 16;
|
|
7
|
+
function validPath(path) { return path.length <= 200 && path.split('/').length <= 8 && path.split('/').every(part => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(part) && !['private', 'env'].includes(part.toLowerCase())); }
|
|
8
|
+
function fail() { throw Error('Registered submodule ownership or pin mismatch'); }
|
|
9
|
+
async function readPlainMetadata(file, limit) {
|
|
10
|
+
const named = await lstat(file);
|
|
11
|
+
if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size > limit || await realpath(file) !== file || (process.getuid && named.uid !== process.getuid()))
|
|
12
|
+
fail();
|
|
13
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
14
|
+
try {
|
|
15
|
+
const before = await handle.stat();
|
|
16
|
+
if (!before.isFile() || before.nlink !== 1 || before.ino !== named.ino || before.dev !== named.dev || before.size !== named.size)
|
|
17
|
+
fail();
|
|
18
|
+
const bytes = Buffer.alloc(limit + 1), read = await handle.read(bytes, 0, bytes.length, 0), [after, current] = await Promise.all([handle.stat(), lstat(file)]);
|
|
19
|
+
if (read.bytesRead !== before.size || read.bytesRead > limit || [after, current].some(stat => !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.ino !== before.ino || stat.dev !== before.dev || stat.size !== before.size || stat.mtimeMs !== before.mtimeMs || stat.ctimeMs !== before.ctimeMs))
|
|
20
|
+
fail();
|
|
21
|
+
return bytes.subarray(0, read.bytesRead).toString('utf8');
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await handle.close();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Eight independent paths at a time; all entries still receive the same ownership checks. */
|
|
28
|
+
async function assertOwnedTree(root, allowObjectHardlinks = false) {
|
|
29
|
+
const pending = [root];
|
|
30
|
+
let entries = 1;
|
|
31
|
+
while (pending.length) {
|
|
32
|
+
const children = await Promise.all(pending.splice(0, 8).map(async (path) => {
|
|
33
|
+
const stat = await lstat(path);
|
|
34
|
+
if (stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || await realpath(path) !== path)
|
|
35
|
+
fail();
|
|
36
|
+
if (stat.isDirectory())
|
|
37
|
+
return (await readdir(path)).map(name => join(path, name));
|
|
38
|
+
if (!stat.isFile() || (!allowObjectHardlinks && stat.nlink !== 1))
|
|
39
|
+
fail();
|
|
40
|
+
return [];
|
|
41
|
+
}));
|
|
42
|
+
for (const paths of children) {
|
|
43
|
+
entries += paths.length;
|
|
44
|
+
if (entries > 10000)
|
|
45
|
+
fail();
|
|
46
|
+
pending.push(...paths);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Object lookup must stay inside the owned store for every later consumer too. */
|
|
51
|
+
export async function assertOwnedGitObjectStorage(mirror) {
|
|
52
|
+
const present = async (path) => { try {
|
|
53
|
+
await lstat(path);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error.code === 'ENOENT')
|
|
58
|
+
return false;
|
|
59
|
+
throw error;
|
|
60
|
+
} };
|
|
61
|
+
for (const path of [mirror, join(mirror, 'objects'), join(mirror, 'objects/info'), join(mirror, 'refs')]) {
|
|
62
|
+
const stat = await lstat(path);
|
|
63
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.getuid && stat.uid !== process.getuid()) || await realpath(path) !== path)
|
|
64
|
+
fail();
|
|
65
|
+
}
|
|
66
|
+
// Local mirror clones can hardlink immutable objects; metadata never may be hardlinked.
|
|
67
|
+
await assertOwnedTree(join(mirror, 'objects'), true);
|
|
68
|
+
// A bare mirror has no common-directory redirect; linked worktree records are validated separately.
|
|
69
|
+
for (const path of [join(mirror, 'commondir'), join(mirror, 'objects/info/alternates'), join(mirror, 'objects/info/http-alternates'), join(mirror, 'refs/replace')])
|
|
70
|
+
if (await present(path))
|
|
71
|
+
fail();
|
|
72
|
+
const packed = join(mirror, 'packed-refs');
|
|
73
|
+
if (await present(packed)) {
|
|
74
|
+
if (/(^|\n)[^\n]*[ \t]refs\/replace(?:\/|[\r\n]|$)/.test(await readPlainMetadata(packed, 1024 * 1024)))
|
|
75
|
+
fail();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export class RegisteredSubmoduleMaterializer {
|
|
79
|
+
client;
|
|
80
|
+
profiles;
|
|
81
|
+
constructor(client, profiles) {
|
|
82
|
+
this.client = client;
|
|
83
|
+
if (profiles.length > 32)
|
|
84
|
+
fail();
|
|
85
|
+
const scopes = new Set();
|
|
86
|
+
this.profiles = Object.freeze(profiles.map(profile => {
|
|
87
|
+
const scope = profile.repositoryKey + ':' + profile.contractBaseCommitSha;
|
|
88
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(profile.repositoryKey) || !(/^[a-f0-9]{40}$/).test(profile.contractBaseCommitSha) || scopes.has(scope) || profile.submodules.length > LIMIT)
|
|
89
|
+
fail();
|
|
90
|
+
scopes.add(scope);
|
|
91
|
+
const paths = [];
|
|
92
|
+
const submodules = profile.submodules.map(item => {
|
|
93
|
+
if (!validPath(item.path) || paths.some(path => path === item.path || path.startsWith(item.path + '/') || item.path.startsWith(path + '/')))
|
|
94
|
+
fail();
|
|
95
|
+
paths.push(item.path);
|
|
96
|
+
// Exact, trusted provider coordinates only; Git helper/ext protocols are forbidden.
|
|
97
|
+
if (item.providerUrl.length > 2048 || /[?#]/.test(item.providerUrl) || !(/^(https:\/\/[^\s]+|ssh:\/\/[^\s]+|git@[A-Za-z0-9.-]+:[A-Za-z0-9_./-]+|\/[^\0\r\n]+)$/).test(item.providerUrl) || item.providerUrl.includes('/../'))
|
|
98
|
+
fail();
|
|
99
|
+
if (item.providerUrl.startsWith('https:') || item.providerUrl.startsWith('ssh:')) {
|
|
100
|
+
const url = new URL(item.providerUrl);
|
|
101
|
+
if (url.username || url.password || !url.hostname)
|
|
102
|
+
fail();
|
|
103
|
+
}
|
|
104
|
+
return Object.freeze({ ...item });
|
|
105
|
+
});
|
|
106
|
+
return Object.freeze({ ...profile, submodules: Object.freeze(submodules) });
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
pins(output) {
|
|
110
|
+
if (output.length > 1024 * 1024)
|
|
111
|
+
fail();
|
|
112
|
+
const result = new Map();
|
|
113
|
+
let count = 0;
|
|
114
|
+
for (const entry of output.split('\0').filter(Boolean)) {
|
|
115
|
+
if (++count > 10000)
|
|
116
|
+
fail();
|
|
117
|
+
const match = /^(\d{6}) (blob|tree|commit) ([a-f0-9]{40})\t(.+)$/.exec(entry);
|
|
118
|
+
if (!match)
|
|
119
|
+
fail();
|
|
120
|
+
if (match[1] === '160000') {
|
|
121
|
+
if (match[2] !== 'commit' || !validPath(match[4]) || result.size >= LIMIT)
|
|
122
|
+
fail();
|
|
123
|
+
if (result.has(match[4]))
|
|
124
|
+
fail();
|
|
125
|
+
result.set(match[4], match[3]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
async directory(path) { const stat = await lstat(path); if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(path) !== path || (process.getuid && stat.uid !== process.getuid()))
|
|
131
|
+
fail(); }
|
|
132
|
+
async present(path) { try {
|
|
133
|
+
await lstat(path);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error.code === 'ENOENT')
|
|
138
|
+
return false;
|
|
139
|
+
throw error;
|
|
140
|
+
} }
|
|
141
|
+
/** Validate the complete owned mirror tree before Git can follow refs/config/object redirects. */
|
|
142
|
+
async mirror(path) {
|
|
143
|
+
await assertOwnedTree(path);
|
|
144
|
+
await assertOwnedGitObjectStorage(path);
|
|
145
|
+
if (await readPlainMetadata(join(path, 'config'), Buffer.byteLength(CONFIG)) !== CONFIG)
|
|
146
|
+
fail();
|
|
147
|
+
}
|
|
148
|
+
async materialize(input) {
|
|
149
|
+
const pins = this.pins((await input.git(['--no-replace-objects', 'ls-tree', '-rz', '--full-tree', input.parent])).stdout);
|
|
150
|
+
const rootPins = input.contractBase === input.parent ? pins : this.pins((await input.git(['--no-replace-objects', 'ls-tree', '-rz', '--full-tree', input.contractBase])).stdout);
|
|
151
|
+
const profile = this.profiles.find(p => p.repositoryKey === input.repositoryKey && p.contractBaseCommitSha === input.contractBase);
|
|
152
|
+
const allowed = profile?.submodules ?? [];
|
|
153
|
+
if (rootPins.size !== pins.size || pins.size !== allowed.length || allowed.some(item => !rootPins.has(item.path) || !pins.has(item.path)))
|
|
154
|
+
fail();
|
|
155
|
+
if (pins.size === 0)
|
|
156
|
+
return;
|
|
157
|
+
const namespace = join(input.root, 'submodule-mirrors');
|
|
158
|
+
if (!await this.present(namespace))
|
|
159
|
+
await mkdir(namespace, { mode: 0o700 });
|
|
160
|
+
await this.directory(namespace);
|
|
161
|
+
for (const item of allowed) {
|
|
162
|
+
const sha = pins.get(item.path);
|
|
163
|
+
// Identity includes workspace and context, preventing other generations/jobs from donating metadata.
|
|
164
|
+
const key = createHash('sha256').update(JSON.stringify([input.repositoryKey, input.contractBase, input.workspace, input.contextFingerprint, item.path, item.providerUrl])).digest('hex');
|
|
165
|
+
const mirror = join(namespace, key + '.git'), checkout = join(input.workspace, item.path);
|
|
166
|
+
const config = ['--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'submodule.recurse=false', '-c', 'protocol.allow=never', '-c', `protocol.${item.providerUrl.startsWith('/') ? 'file' : item.providerUrl.startsWith('https:') ? 'https' : 'ssh'}.allow=always`];
|
|
167
|
+
const run = async (args) => { await this.mirror(mirror); return this.client.run([...config, '--git-dir', mirror, ...args]); };
|
|
168
|
+
// Refuse every existing path component redirect before accessing the checkout.
|
|
169
|
+
let current = input.workspace;
|
|
170
|
+
for (const part of item.path.split('/')) {
|
|
171
|
+
current = join(current, part);
|
|
172
|
+
if (await this.present(current))
|
|
173
|
+
await this.directory(current);
|
|
174
|
+
else
|
|
175
|
+
await mkdir(current, { mode: 0o700 });
|
|
176
|
+
}
|
|
177
|
+
if (await this.present(join(checkout, '.git'))) {
|
|
178
|
+
await this.mirror(mirror);
|
|
179
|
+
await input.assertMetadata(checkout, mirror);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
if ((await readdir(checkout)).length)
|
|
183
|
+
fail();
|
|
184
|
+
if (!await this.present(mirror)) {
|
|
185
|
+
await this.client.run([...config, 'init', '--bare', '--template=', mirror]);
|
|
186
|
+
await writeFile(join(mirror, 'config'), CONFIG, { mode: 0o600 });
|
|
187
|
+
}
|
|
188
|
+
await run(['fetch', '--no-tags', '--depth=1', item.providerUrl, sha]);
|
|
189
|
+
await run(['cat-file', '-e', `${sha}^{commit}`]);
|
|
190
|
+
// Nested submodules have no registration authority in this profile version.
|
|
191
|
+
if (this.pins((await run(['ls-tree', '-rz', '--full-tree', sha])).stdout).size)
|
|
192
|
+
fail();
|
|
193
|
+
await run(['worktree', 'add', '--detach', checkout, sha]);
|
|
194
|
+
}
|
|
195
|
+
const checkoutGit = async (args) => { await this.mirror(mirror); await input.assertMetadata(checkout, mirror); return this.client.run([...config, '-C', checkout, ...args]); };
|
|
196
|
+
if ((await checkoutGit(['rev-parse', 'HEAD'])).stdout.trim() !== sha)
|
|
197
|
+
fail();
|
|
198
|
+
if (this.pins((await checkoutGit(['ls-tree', '-rz', '--full-tree', sha])).stdout).size)
|
|
199
|
+
fail();
|
|
200
|
+
if ((await checkoutGit(['status', '--porcelain', '--untracked-files=all'])).stdout !== '')
|
|
201
|
+
fail();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -1,13 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { validateAttemptContext, attemptContextFingerprint } from '../verification/continuation-runner-contract.js';
|
|
2
|
+
import { access, lstat, mkdir, open, readFile, realpath, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { resolve, join, sep, dirname } from "node:path";
|
|
6
|
+
import { assertOwnedGitObjectStorage, RegisteredSubmoduleMaterializer } from './registered-submodules.js';
|
|
4
7
|
import { isP1WorkingBranch } from '../publication/git-snapshot.js';
|
|
5
8
|
export class WorkspaceManager {
|
|
6
9
|
deps;
|
|
10
|
+
submodules;
|
|
7
11
|
constructor(deps) {
|
|
8
12
|
this.deps = deps;
|
|
13
|
+
this.submodules = new RegisteredSubmoduleMaterializer(deps.git, deps.registeredSubmodules ?? []);
|
|
9
14
|
}
|
|
10
15
|
async create(input) {
|
|
16
|
+
input = { ...input };
|
|
17
|
+
if (input.attemptContext !== undefined || input.attemptContextFingerprint !== undefined) {
|
|
18
|
+
try {
|
|
19
|
+
return await this.createManaged(input);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new Error('CORRECTION_PARENT_UNAVAILABLE');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return this.createManaged(input);
|
|
26
|
+
}
|
|
27
|
+
async createManaged(input) {
|
|
28
|
+
const attempt = input.attemptContext === undefined ? undefined : validateAttemptContext(input.attemptContext);
|
|
29
|
+
if ((input.attemptContextFingerprint !== undefined) !== (attempt !== undefined))
|
|
30
|
+
throw Error('Invalid attempt context');
|
|
31
|
+
if (attempt && (input.attemptContextFingerprint !== attemptContextFingerprint(attempt) || input.jobId !== attempt.job_id || input.repositoryKey !== attempt.repository_key || input.baseCommitSha !== attempt.contract_base_commit_sha || input.workingBranch !== attempt.attempt_working_branch || input.p1WorkingBranch !== attempt.attempt_working_branch))
|
|
32
|
+
throw Error('Attempt identity mismatch');
|
|
33
|
+
const approvedBase = input.baseCommitSha;
|
|
34
|
+
if (attempt)
|
|
35
|
+
input = { ...input, baseCommitSha: attempt.attempt_base_commit_sha };
|
|
36
|
+
const workspaceKey = attempt ? `${input.jobId}.g${attempt.generation}` : input.jobId;
|
|
11
37
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,190}$/.test(input.jobId))
|
|
12
38
|
throw new Error("Invalid job ID for managed workspace");
|
|
13
39
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(input.repositoryKey))
|
|
@@ -23,48 +49,86 @@ export class WorkspaceManager {
|
|
|
23
49
|
const mirrorsRoot = join(root, "mirrors");
|
|
24
50
|
const jobsRoot = join(root, "jobs");
|
|
25
51
|
const mirrorPath = join(mirrorsRoot, `${input.repositoryKey}.git`);
|
|
26
|
-
const workspacePath = join(jobsRoot,
|
|
52
|
+
const workspacePath = join(jobsRoot, workspaceKey);
|
|
27
53
|
this.assertContained(root, mirrorPath);
|
|
28
54
|
this.assertContained(root, workspacePath);
|
|
29
55
|
await mkdir(mirrorsRoot, { recursive: true, mode: 0o700 });
|
|
30
56
|
await mkdir(jobsRoot, { recursive: true, mode: 0o700 });
|
|
57
|
+
if (attempt && (await realpath(mirrorsRoot) !== mirrorsRoot || await realpath(jobsRoot) !== jobsRoot))
|
|
58
|
+
throw Error('Workspace ownership mismatch');
|
|
59
|
+
if (attempt)
|
|
60
|
+
for (const owned of [mirrorPath, workspacePath]) {
|
|
61
|
+
try {
|
|
62
|
+
const stat = await lstat(owned);
|
|
63
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(owned) !== owned)
|
|
64
|
+
throw Error('Workspace ownership mismatch');
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code !== 'ENOENT')
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const manifestPath = join(jobsRoot, `${workspaceKey}.manifest.json`);
|
|
72
|
+
let priorManifest;
|
|
73
|
+
if (attempt && (await this.exists(workspacePath) || await this.exists(manifestPath))) {
|
|
74
|
+
const stat = await lstat(manifestPath);
|
|
75
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.isSymbolicLink() || await realpath(manifestPath) !== manifestPath)
|
|
76
|
+
throw Error('Manifest ownership mismatch');
|
|
77
|
+
priorManifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
78
|
+
if (priorManifest.jobId !== input.jobId || priorManifest.generation !== attempt.generation || priorManifest.attemptContextFingerprint !== input.attemptContextFingerprint || priorManifest.contractBaseCommitSha !== approvedBase || priorManifest.baseCommitSha !== input.baseCommitSha || priorManifest.repositoryKey !== input.repositoryKey || priorManifest.remoteFingerprint !== createHash('sha256').update(input.providerUrl).digest('hex') || priorManifest.retentionState !== 'active' || typeof priorManifest.createdAt !== 'string' || !Number.isFinite(Date.parse(priorManifest.createdAt)))
|
|
79
|
+
throw Error('Manifest identity mismatch');
|
|
80
|
+
}
|
|
81
|
+
const managedGit = async (args) => {
|
|
82
|
+
if (attempt && args[0] === '--git-dir')
|
|
83
|
+
await assertOwnedGitObjectStorage(mirrorPath);
|
|
84
|
+
return this.deps.git.run([...(attempt ? ['--no-replace-objects'] : []), ...args]);
|
|
85
|
+
};
|
|
31
86
|
if (await this.exists(mirrorPath)) {
|
|
32
|
-
const remote = (await
|
|
87
|
+
const remote = (await managedGit(["--git-dir", mirrorPath, "remote", "get-url", "origin"])).stdout.trim();
|
|
33
88
|
if (remote !== input.providerUrl)
|
|
34
89
|
throw new Error("Managed repository remote does not match the registered provider");
|
|
35
90
|
}
|
|
36
91
|
else {
|
|
37
|
-
await
|
|
92
|
+
await managedGit(["clone", "--mirror", input.providerUrl, mirrorPath]);
|
|
38
93
|
}
|
|
39
94
|
try {
|
|
40
|
-
await
|
|
41
|
-
await
|
|
95
|
+
await managedGit(["--git-dir", mirrorPath, "fetch", "--prune", "origin", input.baseCommitSha]);
|
|
96
|
+
await managedGit(["--git-dir", mirrorPath, "cat-file", "-e", `${input.baseCommitSha}^{commit}`]);
|
|
42
97
|
}
|
|
43
98
|
catch {
|
|
44
99
|
throw new Error(`Repository revision ${input.baseCommitSha} is unavailable`);
|
|
45
100
|
}
|
|
46
101
|
if (!await this.exists(workspacePath)) {
|
|
47
|
-
await
|
|
102
|
+
await managedGit(["--git-dir", mirrorPath, "worktree", "add", "--detach", workspacePath, input.baseCommitSha]);
|
|
48
103
|
}
|
|
49
104
|
const actualPath = await realpath(workspacePath);
|
|
50
105
|
this.assertContained(root, actualPath);
|
|
51
|
-
|
|
106
|
+
if (attempt && actualPath !== workspacePath)
|
|
107
|
+
throw Error('Workspace ownership mismatch');
|
|
108
|
+
const workspaceGit = async (args, noHooks = false) => {
|
|
109
|
+
if (attempt)
|
|
110
|
+
await this.assertAttemptGitMetadata(actualPath, mirrorPath);
|
|
111
|
+
return this.deps.git.run([...(attempt ? ['--no-replace-objects'] : []), ...(noHooks ? ['-c', 'core.hooksPath=/dev/null'] : []), '-C', actualPath, ...args]);
|
|
112
|
+
};
|
|
113
|
+
const head = (await workspaceGit(["rev-parse", "HEAD"])).stdout.trim();
|
|
52
114
|
if (head !== input.baseCommitSha)
|
|
53
115
|
throw new Error("Managed workspace HEAD differs from the approved revision");
|
|
54
|
-
|
|
116
|
+
if (attempt)
|
|
117
|
+
await this.submodules.materialize({ workspace: actualPath, root, repositoryKey: input.repositoryKey, contractBase: approvedBase, parent: input.baseCommitSha, contextFingerprint: input.attemptContextFingerprint, git: workspaceGit, assertMetadata: (path, mirror) => this.assertAttemptGitMetadata(path, mirror) });
|
|
118
|
+
const dirty = (await workspaceGit(["status", "--porcelain"])).stdout;
|
|
55
119
|
if (dirty !== "")
|
|
56
120
|
throw new Error("Managed workspace is dirty before execution");
|
|
57
121
|
if (input.workingBranch) {
|
|
58
122
|
const ref = `refs/heads/${input.workingBranch}`;
|
|
59
|
-
const current = (await
|
|
123
|
+
const current = (await workspaceGit(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
|
|
60
124
|
if (current !== 'HEAD' && current !== input.workingBranch)
|
|
61
125
|
throw new Error('Managed fix branch differs from approval');
|
|
62
|
-
const existing = (await
|
|
126
|
+
const existing = (await workspaceGit(['for-each-ref', '--format=%(objectname)', ref])).stdout.trim();
|
|
63
127
|
if (existing && existing !== input.baseCommitSha)
|
|
64
128
|
throw new Error('Managed fix branch revision differs from approval');
|
|
65
129
|
if (!existing)
|
|
66
|
-
await
|
|
67
|
-
await
|
|
130
|
+
await workspaceGit(['update-ref', ref, input.baseCommitSha, '0'.repeat(40)], true);
|
|
131
|
+
await workspaceGit(['symbolic-ref', 'HEAD', ref], true);
|
|
68
132
|
}
|
|
69
133
|
const manifest = {
|
|
70
134
|
jobId: input.jobId,
|
|
@@ -73,13 +137,59 @@ export class WorkspaceManager {
|
|
|
73
137
|
baseCommitSha: head,
|
|
74
138
|
createdAt: new Date().toISOString(),
|
|
75
139
|
retentionState: "active",
|
|
140
|
+
...(attempt ? { generation: attempt.generation, contractBaseCommitSha: approvedBase, attemptContextFingerprint: input.attemptContextFingerprint } : {}),
|
|
76
141
|
};
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
142
|
+
if (priorManifest)
|
|
143
|
+
manifest.createdAt = priorManifest.createdAt;
|
|
144
|
+
const temporaryManifestPath = `${manifestPath}.${attempt ? randomUUID() : process.pid}.tmp`;
|
|
145
|
+
await writeFile(temporaryManifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600, ...(attempt ? { flag: 'wx' } : {}) });
|
|
80
146
|
await rename(temporaryManifestPath, manifestPath);
|
|
81
147
|
return { path: actualPath, mirrorPath, baseCommitSha: head, manifest };
|
|
82
148
|
}
|
|
149
|
+
/** Validate linked-worktree ownership using plain metadata before invoking Git in it.
|
|
150
|
+
* A .git file pointing at a same-SHA foreign repository must never authorize writes. */
|
|
151
|
+
async assertAttemptGitMetadata(workspacePath, mirrorPath) {
|
|
152
|
+
await assertOwnedGitObjectStorage(mirrorPath);
|
|
153
|
+
const dotGit = join(workspacePath, '.git');
|
|
154
|
+
const pointer = await this.readOwnedMetadata(dotGit);
|
|
155
|
+
const match = /^gitdir: ([^\r\n\0]+)\r?\n?$/.exec(pointer);
|
|
156
|
+
if (!match)
|
|
157
|
+
throw Error('Workspace metadata mismatch');
|
|
158
|
+
const gitDirectory = resolve(workspacePath, match[1]);
|
|
159
|
+
const metadataRoot = join(mirrorPath, 'worktrees');
|
|
160
|
+
// Constrain lexically first: never stat/read metadata in a caller-chosen repository.
|
|
161
|
+
if (dirname(gitDirectory) !== metadataRoot || gitDirectory === metadataRoot)
|
|
162
|
+
throw Error('Workspace metadata mismatch');
|
|
163
|
+
for (const directory of [mirrorPath, metadataRoot, gitDirectory]) {
|
|
164
|
+
const stat = await lstat(directory);
|
|
165
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(directory) !== directory || (process.getuid && stat.uid !== process.getuid()))
|
|
166
|
+
throw Error('Workspace metadata mismatch');
|
|
167
|
+
}
|
|
168
|
+
const [common, backpointer] = await Promise.all([this.readOwnedMetadata(join(gitDirectory, 'commondir')), this.readOwnedMetadata(join(gitDirectory, 'gitdir')), this.readOwnedMetadata(join(gitDirectory, 'HEAD'))]);
|
|
169
|
+
const oneLine = (value) => { if (!/^[^\r\n\0]+\r?\n?$/.test(value))
|
|
170
|
+
throw Error('Workspace metadata mismatch'); return value.replace(/\r?\n$/, ''); };
|
|
171
|
+
if (resolve(gitDirectory, oneLine(common)) !== mirrorPath || resolve(gitDirectory, oneLine(backpointer)) !== dotGit)
|
|
172
|
+
throw Error('Workspace metadata mismatch');
|
|
173
|
+
// HEAD was checked alongside both pointers before this workspace may receive a Git command.
|
|
174
|
+
}
|
|
175
|
+
async readOwnedMetadata(file) {
|
|
176
|
+
const named = await lstat(file);
|
|
177
|
+
if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size < 1 || named.size > 4096 || await realpath(file) !== file || (process.getuid && named.uid !== process.getuid()))
|
|
178
|
+
throw Error('Workspace metadata mismatch');
|
|
179
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
180
|
+
try {
|
|
181
|
+
const before = await handle.stat();
|
|
182
|
+
if (!before.isFile() || before.nlink !== 1 || before.ino !== named.ino || before.dev !== named.dev || before.size !== named.size)
|
|
183
|
+
throw Error('Workspace metadata mismatch');
|
|
184
|
+
const bytes = Buffer.alloc(4097), read = await handle.read(bytes, 0, bytes.length, 0), [after, current] = await Promise.all([handle.stat(), lstat(file)]);
|
|
185
|
+
if (read.bytesRead !== before.size || read.bytesRead > 4096 || [after, current].some(stat => !stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.ino !== before.ino || stat.dev !== before.dev || stat.size !== before.size || stat.mtimeMs !== before.mtimeMs || stat.ctimeMs !== before.ctimeMs))
|
|
186
|
+
throw Error('Workspace metadata mismatch');
|
|
187
|
+
return bytes.subarray(0, read.bytesRead).toString('utf8');
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await handle.close();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
83
193
|
assertContained(root, candidate) {
|
|
84
194
|
const resolved = resolve(candidate);
|
|
85
195
|
if (resolved !== root && !resolved.startsWith(`${root}${sep}`))
|