mtech-ai-local-runner 0.1.73 → 0.1.75
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 +35 -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 +31 -10
- package/dist/src/daemon/suspended-publication.js +52 -0
- package/dist/src/gateway-client.js +79 -5
- package/dist/src/protocol.js +33 -0
- package/dist/src/publication/installed-host.js +4 -2
- package/dist/src/publication/server-ci-host.js +85 -31
- 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
|
@@ -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}`))
|