mtech-ai-local-runner 0.1.94 → 0.1.95
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.
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
|
-
import { lstat, mkdir, mkdtemp, open, realpath, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { lstat, mkdir, mkdtemp, open, realpath, readdir, readFile, readlink, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
@@ -11,6 +11,7 @@ const execute = promisify(execFile), denied = () => Error('SNAPSHOT_REJECTED');
|
|
|
11
11
|
const digest = (data) => createHash('sha256').update(data).digest('hex');
|
|
12
12
|
const structural = (p) => p.length > 0 && !path.isAbsolute(p) && !p.includes('\\') && !p.includes('\0') && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^\.git$/i.test(s));
|
|
13
13
|
const safe = (p) => structural(p) && p.split('/').every(s => !/^(?:\.ssh|\.aws|\.codex|\.claude|\.env|\.netrc|\.npmrc|credentials(?:\..*)?)$/i.test(s));
|
|
14
|
+
const TRACKED_SYMLINK = /^120000 blob ([a-f0-9]{40})\t(.+)$/s;
|
|
14
15
|
/** P1 branch shape only. Authority still requires exact frozen-fence equality. */
|
|
15
16
|
/** Whole-checkout memory bound for a snapshot, per file and in total. A snapshot reads every
|
|
16
17
|
* file of the checkout, so callers bound candidate size on the changed files instead. */
|
|
@@ -136,6 +137,19 @@ export class GitSnapshotHost {
|
|
|
136
137
|
await fd.close();
|
|
137
138
|
}
|
|
138
139
|
}
|
|
140
|
+
/** A tracked symlink must stay the exact base link. Its target is read with readlink and never followed. */
|
|
141
|
+
async assertTrackedSymlink(relative, target) {
|
|
142
|
+
let component = this.options.root;
|
|
143
|
+
const parts = relative.split('/');
|
|
144
|
+
for (let i = 0; i < parts.length; i++) {
|
|
145
|
+
component = path.join(component, parts[i]);
|
|
146
|
+
const stat = await lstat(component);
|
|
147
|
+
if (i < parts.length - 1 ? stat.isSymbolicLink() || !stat.isDirectory() : !stat.isSymbolicLink())
|
|
148
|
+
throw denied();
|
|
149
|
+
}
|
|
150
|
+
if (!(await readlink(component, { encoding: 'buffer' })).equals(target))
|
|
151
|
+
throw denied();
|
|
152
|
+
}
|
|
139
153
|
/** Checks the options every read relies on, then that HEAD is the base commit on the working branch. */
|
|
140
154
|
async head() {
|
|
141
155
|
const o = this.options, branchAllowed = o.p1WorkingBranch === undefined ? /^ai-work\/local\/[A-Za-z0-9-]+$/.test(o.workingBranch) : o.p1WorkingBranch === o.workingBranch && isP1WorkingBranch(o.workingBranch);
|
|
@@ -149,7 +163,7 @@ export class GitSnapshotHost {
|
|
|
149
163
|
const o = this.options;
|
|
150
164
|
await this.head();
|
|
151
165
|
const head = o.baseCommitSha, branch = o.workingBranch;
|
|
152
|
-
const base = new Map(), gitlinks = new Map(), protectedNames = new Set(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
|
|
166
|
+
const base = new Map(), gitlinks = new Map(), symlinks = new Map(), protectedNames = new Set(), baseListing = await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha]);
|
|
153
167
|
for (const line of baseListing.toString().split('\0').filter(Boolean)) {
|
|
154
168
|
const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
155
169
|
if (link) {
|
|
@@ -158,6 +172,13 @@ export class GitSnapshotHost {
|
|
|
158
172
|
gitlinks.set(link[2], link[1]);
|
|
159
173
|
continue;
|
|
160
174
|
}
|
|
175
|
+
const symlink = TRACKED_SYMLINK.exec(line);
|
|
176
|
+
if (symlink) {
|
|
177
|
+
if (!safe(symlink[2]))
|
|
178
|
+
throw denied();
|
|
179
|
+
symlinks.set(symlink[2], symlink[1]);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
161
182
|
const match = /^(100644|100755) blob ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
162
183
|
if (!match || !structural(match[3]))
|
|
163
184
|
throw denied();
|
|
@@ -166,11 +187,11 @@ export class GitSnapshotHost {
|
|
|
166
187
|
protectedNames.add(match[3]);
|
|
167
188
|
}
|
|
168
189
|
// Protected names the base commit already tracks stay as exact base content; only a change to one is refused.
|
|
169
|
-
// Gitlinks remain exact base-tree references. Never traverse or copy their source.
|
|
190
|
+
// Gitlinks and tracked symlinks remain exact base-tree references. Never traverse or copy their source.
|
|
170
191
|
// Staged replacement/deletion is refused rather than publishing hidden changes.
|
|
171
192
|
const staged = new Map(), stagedListing = await this.git(['ls-files', '--stage', '-z']);
|
|
172
193
|
for (const line of stagedListing.toString().split('\0').filter(Boolean)) {
|
|
173
|
-
const entry = /^(100644|100755|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
|
|
194
|
+
const entry = /^(100644|100755|120000|160000) ([a-f0-9]{40}) 0\t(.+)$/s.exec(line);
|
|
174
195
|
if (!entry || !structural(entry[3]))
|
|
175
196
|
throw denied();
|
|
176
197
|
staged.set(entry[3], entry[1] + ' ' + entry[2]);
|
|
@@ -178,15 +199,26 @@ export class GitSnapshotHost {
|
|
|
178
199
|
const expected = new Map([...base.entries()].map(([name, value]) => [name, value.mode.toString(8) + ' ' + value.blob]));
|
|
179
200
|
for (const [name, oid] of gitlinks)
|
|
180
201
|
expected.set(name, '160000 ' + oid);
|
|
202
|
+
for (const [name, oid] of symlinks)
|
|
203
|
+
expected.set(name, '120000 ' + oid);
|
|
181
204
|
if (protocolHash([...expected].sort()) !== protocolHash([...staged].sort()))
|
|
182
205
|
throw denied();
|
|
183
206
|
const untrackedListing = await this.git(['ls-files', '--others', '--exclude-standard', '-z', '--']);
|
|
184
207
|
const names = [...new Set([...base.keys(), ...untrackedListing.toString().split('\0').filter(Boolean)])].sort();
|
|
185
|
-
const blobs = await this.batchObjects([...base.values()].map(v => v.blob));
|
|
208
|
+
const blobs = await this.batchObjects([...[...base.values()].map(v => v.blob), ...symlinks.values()]);
|
|
209
|
+
const symlinkTargets = new Map();
|
|
210
|
+
for (const [name, oid] of symlinks) {
|
|
211
|
+
const blob = blobs.get(oid);
|
|
212
|
+
if (!blob || blob.type !== 'blob')
|
|
213
|
+
throw denied();
|
|
214
|
+
symlinkTargets.set(name, blob.bytes);
|
|
215
|
+
await this.assertTrackedSymlink(name, blob.bytes);
|
|
216
|
+
}
|
|
217
|
+
const kept = [...gitlinks.keys(), ...symlinks.keys()];
|
|
186
218
|
const files = [], changes = [];
|
|
187
219
|
let total = 0;
|
|
188
220
|
for (const name of names) {
|
|
189
|
-
if (
|
|
221
|
+
if (kept.some(p => name === p || name.startsWith(p + '/') || p.startsWith(name + '/')))
|
|
190
222
|
throw denied();
|
|
191
223
|
const current = await this.currentFile(name, protectedNames.has(name)), prior = base.get(name);
|
|
192
224
|
let before = null;
|
|
@@ -211,7 +243,7 @@ export class GitSnapshotHost {
|
|
|
211
243
|
changes.push({ path: name, status: !prior ? 'added' : !current ? 'deleted' : 'modified', before: before?.toString('base64') ?? null, after: current?.bytes.toString('base64') ?? null, before_mode: prior?.mode ?? null, after_mode: current?.mode ?? null });
|
|
212
244
|
}
|
|
213
245
|
const observation = { base_commit_sha: o.baseCommitSha, head_commit_sha: head, working_branch: branch, diff: JSON.stringify({ format: 'local-candidate-binary-diff-v1', changes }), changed_files: changes.map(({ path, status }) => ({ path, status })) };
|
|
214
|
-
const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names, protectedNames };
|
|
246
|
+
const listing = { base: digest(baseListing), staged: digest(stagedListing), untracked: digest(untrackedListing), names, protectedNames, symlinks: symlinkTargets };
|
|
215
247
|
return { observation, files, listing, fingerprint: protocolHash({ observation, files: files.map(f => ({ path: f.path, mode: f.mode, sha256: digest(f.bytes) })) }) };
|
|
216
248
|
}
|
|
217
249
|
/** The checkout still equals a capture when HEAD, the branch, the three Git listings and every
|
|
@@ -222,6 +254,8 @@ export class GitSnapshotHost {
|
|
|
222
254
|
await this.head();
|
|
223
255
|
if (digest(await this.git(['ls-tree', '-r', '-z', '--full-tree', o.baseCommitSha])) !== listing.base || digest(await this.git(['ls-files', '--stage', '-z'])) !== listing.staged || digest(await this.git(['ls-files', '--others', '--exclude-standard', '-z', '--'])) !== listing.untracked)
|
|
224
256
|
throw denied();
|
|
257
|
+
for (const [name, target] of listing.symlinks)
|
|
258
|
+
await this.assertTrackedSymlink(name, target);
|
|
225
259
|
const files = [];
|
|
226
260
|
let total = 0;
|
|
227
261
|
for (const name of listing.names) {
|
|
@@ -264,7 +298,7 @@ export class GitSnapshotHost {
|
|
|
264
298
|
for (const entry of (await this.git(['ls-tree', '-r', '-t', '-z', o.baseCommitSha])).toString().split('\0').filter(Boolean)) {
|
|
265
299
|
if (/^160000 commit [a-f0-9]{40}\t/.test(entry))
|
|
266
300
|
continue;
|
|
267
|
-
const match = /^(?:100644|100755|040000) (blob|tree) ([a-f0-9]{40})\t/.exec(entry);
|
|
301
|
+
const match = /^(?:100644|100755|120000|040000) (blob|tree) ([a-f0-9]{40})\t/.exec(entry);
|
|
268
302
|
if (!match)
|
|
269
303
|
throw denied();
|
|
270
304
|
objects.set(match[2], match[1]);
|
|
@@ -344,6 +378,9 @@ export class GitSnapshotHost {
|
|
|
344
378
|
const link = /^160000 commit ([a-f0-9]{40})\t(.+)$/s.exec(line);
|
|
345
379
|
if (link)
|
|
346
380
|
entries.push({ path: link[2], mode: 0o160000, bytes: Buffer.alloc(0), gitlink: link[1] });
|
|
381
|
+
const symlink = TRACKED_SYMLINK.exec(line);
|
|
382
|
+
if (symlink)
|
|
383
|
+
entries.push({ path: symlink[2], mode: 0o120000, bytes: Buffer.alloc(0), baseBlob: symlink[1] });
|
|
347
384
|
}
|
|
348
385
|
const objects = new RawGitObjects(root), tree = await objects.tree(entries, true), commitSha = await objects.object('commit', Buffer.from(`tree ${tree}\nparent ${this.options.baseCommitSha}\nauthor Local Runner Verification <runner-verification@invalid> 1 +0000\ncommitter Local Runner Verification <runner-verification@invalid> 1 +0000\n\nLocal runner verification candidate v1\n`), true);
|
|
349
386
|
await writeFile(path.join(root, '.git', 'HEAD'), commitSha + '\n', { mode: 0o600 });
|
|
@@ -67,13 +67,13 @@ export class GitPublicationTransport {
|
|
|
67
67
|
if (protocolHash(entries.map(e => ({ path: e.path, mode: e.mode, sha256: createHash('sha256').update(e.bytes).digest('hex') }))) !== o.sealedTreeFingerprint)
|
|
68
68
|
throw denied();
|
|
69
69
|
for (const line of (await this.git(['ls-tree', '-r', '-z', request.base_commit_sha])).split('\0').filter(Boolean)) {
|
|
70
|
-
const gitlink = /^160000 commit ([a-f0-9]{40})\t(.+)$/.exec(line);
|
|
71
|
-
if (!
|
|
70
|
+
const gitlink = /^160000 commit ([a-f0-9]{40})\t(.+)$/.exec(line), symlink = /^120000 blob ([a-f0-9]{40})\t(.+)$/.exec(line), kept = gitlink ?? symlink;
|
|
71
|
+
if (!kept)
|
|
72
72
|
continue;
|
|
73
|
-
const name =
|
|
73
|
+
const name = kept[2];
|
|
74
74
|
if (!name || name.split('/').some(p => !p || p === '.' || p === '..' || p === '.git') || name.includes('\\') || entries.some(e => e.path === name || e.path.startsWith(name + '/') || name.startsWith(e.path + '/')))
|
|
75
75
|
throw denied();
|
|
76
|
-
entries.push({ path: name, mode: 0o160000, bytes: Buffer.alloc(0), gitlink: gitlink[1] });
|
|
76
|
+
entries.push(gitlink ? { path: name, mode: 0o160000, bytes: Buffer.alloc(0), gitlink: gitlink[1] } : { path: name, mode: 0o120000, bytes: Buffer.alloc(0), baseBlob: symlink[1] });
|
|
77
77
|
}
|
|
78
78
|
if (!await o.current())
|
|
79
79
|
throw denied();
|
|
@@ -42,7 +42,7 @@ export class RawGitObjects {
|
|
|
42
42
|
if (slash >= 0)
|
|
43
43
|
children.add(rest.slice(0, slash));
|
|
44
44
|
else
|
|
45
|
-
names.set(rest, { mode: entry.mode.toString(8), oid: entry.gitlink ?? await this.object('blob', entry.bytes, write) });
|
|
45
|
+
names.set(rest, { mode: entry.mode.toString(8), oid: entry.gitlink ?? entry.baseBlob ?? await this.object('blob', entry.bytes, write) });
|
|
46
46
|
}
|
|
47
47
|
for (const child of children)
|
|
48
48
|
names.set(child, { mode: '40000', oid: await this.tree(entries, write, prefix + child + '/') });
|