mtech-ai-local-runner 0.1.76 → 0.1.79
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 +48 -0
- package/claude-mod/.claude-plugin/marketplace.json +1 -0
- package/claude-mod/.claude-plugin/plugin.json +1 -0
- package/claude-mod/hooks/gateway.ts +47 -0
- package/claude-mod/hooks/hooks.json +1 -0
- package/claude-mod/hooks/model.ts +155 -0
- package/claude-mod/hooks/register.tsx +269 -0
- package/claude-mod/types/index.d.ts +22 -0
- package/dist/src/adapters/claude-code/adapter.js +16 -5
- package/dist/src/adapters/claude-code/prompt-compiler.js +17 -4
- package/dist/src/adapters/claude-code/runtime-options.js +4 -1
- package/dist/src/adapters/codex/config-builder.js +2 -1
- package/dist/src/adapters/codex/exec-invocation.js +6 -1
- package/dist/src/adapters/codex/exec-preflight.js +4 -2
- package/dist/src/adapters/codex/exec-runtime-options.js +3 -3
- package/dist/src/adapters/codex/exec-tool-gate.js +7 -3
- package/dist/src/adapters/codex/native-policy.js +2 -2
- package/dist/src/adapters/codex/native-session.js +5 -4
- package/dist/src/adapters/codex/pre-model-gate.js +7 -3
- package/dist/src/asks/claude-mod.js +163 -0
- package/dist/src/asks/commands.js +26 -6
- package/dist/src/asks/doctor.js +4 -3
- package/dist/src/asks/integrations.js +1 -1
- package/dist/src/cli.js +39 -5
- package/dist/src/commands/fix.js +6 -1
- package/dist/src/daemon/execution-coordinator.js +3 -0
- package/dist/src/daemon/repository-access-monitor.js +66 -0
- package/dist/src/daemon/runtime.js +16 -1
- package/dist/src/gateway-client.js +2 -0
- package/dist/src/safety/claude-production-host.js +20 -2
- package/dist/src/safety/claude-release.js +4 -1
- package/dist/src/safety/claude-review-campaign.js +4 -4
- package/dist/src/safety/claude-review-run.js +7 -4
- package/dist/src/safety/codex-exec-production-host.js +7 -5
- package/dist/src/safety/codex-manual-controller.js +2 -2
- package/dist/src/safety/hook-policy-builder.js +3 -3
- package/dist/src/safety/job-tool-dispatcher.js +14 -3
- package/dist/src/safety/job-tool-session.js +2 -2
- package/dist/src/safety/local-tool-policy.js +229 -3
- package/dist/src/safety/local-tool-self-test.js +44 -0
- package/dist/src/safety/local-tool-server.js +18 -6
- package/dist/src/safety/local-tool-worker.js +43 -0
- package/dist/src/safety/pre-tool-use-hook.js +17 -2
- package/dist/src/workspace/git-client.js +5 -1
- package/dist/src/workspace/repository-access.js +95 -0
- package/dist/src/workspace/workspace-manager.js +17 -4
- package/package.json +3 -1
|
@@ -2,12 +2,21 @@ import { validateLocalReadRange } from './local-read-range.js';
|
|
|
2
2
|
import { isPatchContent } from './local-edit-content.js';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
|
-
import { lstat, realpath } from 'node:fs/promises';
|
|
5
|
+
import { lstat, opendir, realpath } from 'node:fs/promises';
|
|
6
6
|
import { evaluateCommand } from './command-policy.js';
|
|
7
7
|
import { runSandboxed, runSandboxedMeasured } from './subprocess-sandbox.js';
|
|
8
8
|
import { LOCAL_TOOL_WORKER } from './local-tool-worker.js';
|
|
9
9
|
import { verifyCommandIntegrity } from './registered-command-integrity.js';
|
|
10
10
|
export const LOCAL_TOOL_NAMES = ['local_read', 'local_search', 'local_edit', 'local_replace', 'local_test'];
|
|
11
|
+
/** Named tool inventories (Work Decision f43e7aa0). v1 is the original five, unchanged; v2 adds the read-only local_find.
|
|
12
|
+
* A job serves exactly one: the one whose fingerprint its reviewed input names (local-tool-server.ts localToolInventoryFor). */
|
|
13
|
+
export const LOCAL_TOOL_INVENTORIES = Object.freeze({ 'local-tools-v1': LOCAL_TOOL_NAMES, 'local-tools-v2': Object.freeze([...LOCAL_TOOL_NAMES, 'local_find']) });
|
|
14
|
+
/** The tool names of one inventory; an unknown id fails closed. */
|
|
15
|
+
export function localToolNames(id = 'local-tools-v1') {
|
|
16
|
+
if (!Object.hasOwn(LOCAL_TOOL_INVENTORIES, id))
|
|
17
|
+
throw new Error('DENIED_TOOL_INVENTORY');
|
|
18
|
+
return LOCAL_TOOL_INVENTORIES[id];
|
|
19
|
+
}
|
|
11
20
|
// Private state/control basenames only: Auth.php, auth/ and mcp.ts are ordinary source.
|
|
12
21
|
const sensitive = (segment) => /^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(segment);
|
|
13
22
|
const contains = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
@@ -17,13 +26,29 @@ function scopePath(root, scope) {
|
|
|
17
26
|
throw new Error('DENIED_UNSUPPORTED_SCOPE');
|
|
18
27
|
return path.resolve(root, directory);
|
|
19
28
|
}
|
|
20
|
-
function
|
|
29
|
+
function exactBlockedScopeContains(root, scope, file) {
|
|
21
30
|
// The compiled repository policy includes this private-file family. Handle
|
|
22
31
|
// it only as a denial, never as a wildcard grant or an ignored block.
|
|
23
32
|
if (scope === '.env.*')
|
|
24
33
|
return file.startsWith(path.join(root, '.env.'));
|
|
25
34
|
return contains(scopePath(root, scope), file);
|
|
26
35
|
}
|
|
36
|
+
// APFS and HFS+ resolve names regardless of case and Unicode normalization, so app/secrets reaches a blocked
|
|
37
|
+
// app/Secrets. A block therefore also matches the folded spelling: a variant can only be refused, never granted.
|
|
38
|
+
const fold = (value) => value.normalize('NFC').toLowerCase();
|
|
39
|
+
function blockedScopeContains(root, scope, file) {
|
|
40
|
+
return exactBlockedScopeContains(root, scope, file) || exactBlockedScopeContains(fold(root), fold(scope), fold(file));
|
|
41
|
+
}
|
|
42
|
+
/** local_find bounds (design 2026-10-30-local-claude-file-discovery). Reaching one returns `truncated: true`, never an error. */
|
|
43
|
+
export const LOCAL_FIND_BOUNDS = Object.freeze({ depth: 16, entries: 20_000, candidates: 2_000, candidateBytes: 32 * 1024 * 1024, fileBytes: 1024 * 1024,
|
|
44
|
+
lineChars: 300, resultBytes: 64 * 1024, timeMs: 10_000,
|
|
45
|
+
/** Not in the design: Seatbelt compile time grows faster than the number of read grants (2,000 take 4 to 8 s), so
|
|
46
|
+
* the candidates are searched by one sandboxed worker per batch, each granted exactly its batch. */
|
|
47
|
+
batchFiles: 250,
|
|
48
|
+
/** Kept for sandbox startup and exit inside the overall time. */
|
|
49
|
+
workerReserveMs: 1_500 });
|
|
50
|
+
const FIND_KEYS = ['path', 'name', 'text', 'max_results'];
|
|
51
|
+
const DEPENDENCY_DIRECTORIES = ['vendor', 'node_modules'];
|
|
27
52
|
export class LocalToolPolicy {
|
|
28
53
|
options;
|
|
29
54
|
abort = new AbortController();
|
|
@@ -31,16 +56,19 @@ export class LocalToolPolicy {
|
|
|
31
56
|
this.options = structuredClone(options);
|
|
32
57
|
if (!path.isAbsolute(options.worktreeRoot))
|
|
33
58
|
throw new Error('DENIED_ROOT');
|
|
59
|
+
localToolNames(options.inventory);
|
|
34
60
|
}
|
|
35
61
|
revoke() { this.abort.abort(); }
|
|
36
62
|
async call(tool, args) {
|
|
37
63
|
if (this.abort.signal.aborted)
|
|
38
64
|
throw new Error('DENIED_REVOKED');
|
|
39
|
-
if (!
|
|
65
|
+
if (!localToolNames(this.options.inventory).includes(tool))
|
|
40
66
|
throw new Error('DENIED_TOOL');
|
|
41
67
|
if (!args || typeof args !== 'object' || Array.isArray(args))
|
|
42
68
|
throw new Error('DENIED_ARGUMENTS');
|
|
43
69
|
const input = args;
|
|
70
|
+
if (tool === 'local_find')
|
|
71
|
+
return this.find(input);
|
|
44
72
|
if ((tool === 'local_edit' || tool === 'local_replace') && this.options.access !== 'write')
|
|
45
73
|
throw new Error('DENIED_READ_ONLY');
|
|
46
74
|
if (tool === 'local_test')
|
|
@@ -104,6 +132,204 @@ export class LocalToolPolicy {
|
|
|
104
132
|
throw new Error('DENIED_SIZE');
|
|
105
133
|
return file;
|
|
106
134
|
}
|
|
135
|
+
/** The scope rules of authorizedFile, for a directory: inside an allowed scope or above one, never blocked. */
|
|
136
|
+
async authorizedDirectory(value) {
|
|
137
|
+
const root = path.resolve(this.options.worktreeRoot);
|
|
138
|
+
if (value !== undefined && (typeof value !== 'string' || !value || value.includes('\0') || path.isAbsolute(value) || value.split(/[\\/]/).some(p => p === '..' || sensitive(p))))
|
|
139
|
+
throw new Error('DENIED_PATH');
|
|
140
|
+
const directory = value === undefined ? root : path.resolve(root, value);
|
|
141
|
+
if (!contains(root, directory) || !this.options.allowedPaths.some(scope => { const allowed = scopePath(root, scope); return contains(allowed, directory) || contains(directory, allowed); })
|
|
142
|
+
|| this.options.blockedPaths.some(scope => blockedScopeContains(root, scope, directory)))
|
|
143
|
+
throw new Error('DENIED_SCOPE');
|
|
144
|
+
if (await realpath(root) !== root)
|
|
145
|
+
throw new Error('DENIED_ROOT');
|
|
146
|
+
let component = root;
|
|
147
|
+
for (const segment of path.relative(root, directory).split(path.sep).filter(Boolean)) {
|
|
148
|
+
component = path.join(component, segment);
|
|
149
|
+
if ((await lstat(component)).isSymbolicLink())
|
|
150
|
+
throw new Error('DENIED_SYMLINK');
|
|
151
|
+
}
|
|
152
|
+
const stat = await lstat(directory, { bigint: true });
|
|
153
|
+
if (!stat.isDirectory())
|
|
154
|
+
throw new Error('DENIED_FILE');
|
|
155
|
+
return { root, directory, dev: stat.dev, ino: stat.ino };
|
|
156
|
+
}
|
|
157
|
+
/** Read-only discovery. Names are walked on the host with opendir and lstat only, under the scope rules of
|
|
158
|
+
* authorizedFile, never following a symlink. File content is read only by the sandboxed worker, with a read grant
|
|
159
|
+
* for exactly the bounded candidate list, and each file re-checked there as local_read does. */
|
|
160
|
+
async find(input) {
|
|
161
|
+
const bounds = { ...LOCAL_FIND_BOUNDS, ...this.options.findBounds }, deadline = Date.now() + bounds.timeMs;
|
|
162
|
+
if (Object.keys(input).some(key => !FIND_KEYS.includes(key)))
|
|
163
|
+
throw new Error('DENIED_ARGUMENTS');
|
|
164
|
+
const { name, text } = input;
|
|
165
|
+
if (name !== undefined && (typeof name !== 'string' || !name || name.length > 256) || text !== undefined && (typeof text !== 'string' || !text || text.length > 4096) || name === undefined && text === undefined)
|
|
166
|
+
throw new Error('DENIED_QUERY');
|
|
167
|
+
const max = input.max_results ?? 100;
|
|
168
|
+
if (!Number.isSafeInteger(max) || max < 1 || max > 200)
|
|
169
|
+
throw new Error('DENIED_ARGUMENTS');
|
|
170
|
+
const limit = max, needle = typeof name === 'string' ? name.toLowerCase() : undefined;
|
|
171
|
+
const start = await this.authorizedDirectory(input.path), root = start.root;
|
|
172
|
+
const allowed = this.options.allowedPaths.map(scope => scopePath(root, scope));
|
|
173
|
+
const blocked = (file) => this.options.blockedPaths.some(scope => blockedScopeContains(root, scope, file));
|
|
174
|
+
// vendor/ and node_modules/ are walked only when the requested directory is inside one of them.
|
|
175
|
+
const dependencies = path.relative(root, start.directory).split(path.sep).some(segment => DEPENDENCY_DIRECTORIES.includes(segment));
|
|
176
|
+
const names = [], candidates = [];
|
|
177
|
+
let visited = 0, files = 0, truncated = false;
|
|
178
|
+
const stack = [{ directory: start.directory, depth: 0, dev: start.dev, ino: start.ino }];
|
|
179
|
+
walk: while (stack.length) {
|
|
180
|
+
const next = stack.pop();
|
|
181
|
+
if (this.abort.signal.aborted)
|
|
182
|
+
throw new Error('DENIED_REVOKED');
|
|
183
|
+
if (Date.now() >= deadline || visited >= bounds.entries) {
|
|
184
|
+
truncated = true;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
// The directory must still be the inode its parent listed, before and after reading it: opendir follows a symlink.
|
|
188
|
+
const same = async () => { try {
|
|
189
|
+
const s = await lstat(next.directory, { bigint: true });
|
|
190
|
+
return s.isDirectory() && s.dev === next.dev && s.ino === next.ino;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
} };
|
|
195
|
+
if (!await same())
|
|
196
|
+
continue;
|
|
197
|
+
const entries = [];
|
|
198
|
+
// A directory this user cannot read is left out, like any entry the walk skips.
|
|
199
|
+
const handle = await opendir(next.directory, { bufferSize: 64 }).catch(() => undefined);
|
|
200
|
+
if (!handle)
|
|
201
|
+
continue;
|
|
202
|
+
try {
|
|
203
|
+
for (;;) {
|
|
204
|
+
const entry = await handle.read();
|
|
205
|
+
if (!entry)
|
|
206
|
+
break;
|
|
207
|
+
if (visited + entries.length >= bounds.entries) {
|
|
208
|
+
truncated = true;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
entries.push(entry.name);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await handle.close();
|
|
216
|
+
}
|
|
217
|
+
if (!await same())
|
|
218
|
+
continue;
|
|
219
|
+
visited += entries.length;
|
|
220
|
+
const directories = [];
|
|
221
|
+
for (const entry of entries.sort()) {
|
|
222
|
+
if (Date.now() >= deadline) {
|
|
223
|
+
truncated = true;
|
|
224
|
+
break walk;
|
|
225
|
+
}
|
|
226
|
+
const file = path.join(next.directory, entry);
|
|
227
|
+
if (entry.split(/[\\/]/).some(p => p === '..' || sensitive(p)) || blocked(file))
|
|
228
|
+
continue;
|
|
229
|
+
let stat;
|
|
230
|
+
try {
|
|
231
|
+
stat = await lstat(file, { bigint: true });
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (stat.isSymbolicLink())
|
|
237
|
+
continue;
|
|
238
|
+
if (stat.isDirectory()) {
|
|
239
|
+
if (!dependencies && DEPENDENCY_DIRECTORIES.includes(entry) || !allowed.some(scope => contains(scope, file) || contains(file, scope)))
|
|
240
|
+
continue;
|
|
241
|
+
if (next.depth >= bounds.depth) {
|
|
242
|
+
truncated = true;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
directories.push({ directory: file, depth: next.depth + 1, dev: stat.dev, ino: stat.ino });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (!stat.isFile() || !allowed.some(scope => contains(scope, file)))
|
|
249
|
+
continue;
|
|
250
|
+
const rel = path.relative(root, file);
|
|
251
|
+
if (text === undefined) {
|
|
252
|
+
files++;
|
|
253
|
+
if (rel.toLowerCase().includes(needle))
|
|
254
|
+
names.push(rel);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (needle !== undefined && !rel.toLowerCase().includes(needle) || stat.nlink !== 1n || stat.size > BigInt(bounds.fileBytes))
|
|
258
|
+
continue;
|
|
259
|
+
candidates.push({ rel, file, dev: stat.dev, ino: stat.ino, size: stat.size });
|
|
260
|
+
}
|
|
261
|
+
stack.push(...directories.reverse());
|
|
262
|
+
}
|
|
263
|
+
// Bounds cut in path order, after sorting, so a result always holds the first paths (the walk is bounded by entries).
|
|
264
|
+
if (text === undefined) {
|
|
265
|
+
names.sort();
|
|
266
|
+
if (names.length > limit) {
|
|
267
|
+
truncated = true;
|
|
268
|
+
names.length = limit;
|
|
269
|
+
}
|
|
270
|
+
return this.boundedFind(names.map(rel => ({ path: rel })), truncated, files);
|
|
271
|
+
}
|
|
272
|
+
candidates.sort((a, b) => a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0);
|
|
273
|
+
let bytes = 0n, kept = 0;
|
|
274
|
+
for (const candidate of candidates) {
|
|
275
|
+
if (kept >= bounds.candidates || bytes + candidate.size > BigInt(bounds.candidateBytes)) {
|
|
276
|
+
truncated = true;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
bytes += candidate.size;
|
|
280
|
+
kept++;
|
|
281
|
+
}
|
|
282
|
+
candidates.length = kept;
|
|
283
|
+
const matches = [];
|
|
284
|
+
let scanned = 0, size = 0;
|
|
285
|
+
for (let offset = 0; offset < candidates.length; offset += bounds.batchFiles) {
|
|
286
|
+
const remaining = deadline - Date.now(), batch = candidates.slice(offset, offset + bounds.batchFiles), slots = limit - matches.length;
|
|
287
|
+
if (remaining <= bounds.workerReserveMs) {
|
|
288
|
+
truncated = true;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
let output;
|
|
292
|
+
try {
|
|
293
|
+
output = await runSandboxed({ executable: process.execPath, argv: ['--no-addons', '--no-global-search-paths', '--eval', LOCAL_TOOL_WORKER, JSON.stringify({ tool: 'local_find', text, max_results: slots,
|
|
294
|
+
budget_ms: remaining - bounds.workerReserveMs, files: batch.map(c => [c.file, String(c.dev), String(c.ino)]) })], readFiles: batch.map(c => c.file), timeoutMs: remaining, signal: this.abort.signal });
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
// A batch stopped at the overall time limit is the time bound: keep what was found and say truncated.
|
|
298
|
+
if (!this.abort.signal.aborted && Date.now() >= deadline - 50) {
|
|
299
|
+
truncated = true;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
if (this.abort.signal.aborted)
|
|
305
|
+
throw new Error('DENIED_REVOKED');
|
|
306
|
+
const result = JSON.parse(output);
|
|
307
|
+
if (!Array.isArray(result.matches) || result.matches.length > slots || !Number.isSafeInteger(result.scanned) || result.scanned < 0 || result.scanned > batch.length
|
|
308
|
+
|| result.matches.some(m => !Array.isArray(m) || !Number.isSafeInteger(m[0]) || m[0] < 0 || m[0] >= batch.length || !Number.isSafeInteger(m[1]) || m[1] < 1 || typeof m[2] !== 'string'))
|
|
309
|
+
throw new Error('LOCAL_FIND_RESULT_REJECTED');
|
|
310
|
+
scanned += result.scanned;
|
|
311
|
+
for (const [index, line, value] of result.matches) {
|
|
312
|
+
const match = { path: batch[index].rel, line, text: value.slice(0, bounds.lineChars) };
|
|
313
|
+
matches.push(match);
|
|
314
|
+
size += Buffer.byteLength(JSON.stringify(match)) + 1;
|
|
315
|
+
}
|
|
316
|
+
if (result.truncated === true || size > bounds.resultBytes) {
|
|
317
|
+
truncated = true;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return this.boundedFind(matches, truncated, scanned);
|
|
322
|
+
}
|
|
323
|
+
/** At most 64 KiB of JSON: matches are dropped from the end, which is the end of the path order. */
|
|
324
|
+
boundedFind(matches, truncated, scanned) {
|
|
325
|
+
const result = { matches, truncated, scanned_files: scanned };
|
|
326
|
+
let size = Buffer.byteLength(JSON.stringify(result));
|
|
327
|
+
while (size > LOCAL_FIND_BOUNDS.resultBytes) {
|
|
328
|
+
size -= Buffer.byteLength(JSON.stringify(matches.pop())) + 1;
|
|
329
|
+
result.truncated = true;
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
107
333
|
async prepareTest(input) {
|
|
108
334
|
if (this.abort.signal.aborted)
|
|
109
335
|
throw new Error('DENIED_REVOKED');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { LocalToolPolicy } from './local-tool-policy.js';
|
|
4
|
+
import { runSandboxedMeasured } from './subprocess-sandbox.js';
|
|
5
|
+
const PROBE = 'runner doctor probe\n';
|
|
6
|
+
/**
|
|
7
|
+
* Reads and searches a probe file with the same LocalToolPolicy and sandbox a job's local_read and
|
|
8
|
+
* local_search use, in a fresh directory under the workspace root that holds job checkouts, then
|
|
9
|
+
* removes it (Work Item 6f9e64ae: every file tool call failed on one employee's Mac, and a job
|
|
10
|
+
* reports only LOCAL_TOOL_FAILED). No repository content is read.
|
|
11
|
+
*/
|
|
12
|
+
export async function localToolSelfTest(workspaceRoot, deps = {}) {
|
|
13
|
+
const node = process.execPath;
|
|
14
|
+
const parent = path.join(workspaceRoot, 'doctor');
|
|
15
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
16
|
+
const root = await mkdtemp(path.join(parent, 'local-tools-'));
|
|
17
|
+
let tool = 'local_read';
|
|
18
|
+
try {
|
|
19
|
+
await mkdir(path.join(root, 'src'), { mode: 0o700 });
|
|
20
|
+
await writeFile(path.join(root, 'src', 'probe.txt'), PROBE, { mode: 0o600 });
|
|
21
|
+
const policy = deps.createPolicy?.(root)
|
|
22
|
+
?? new LocalToolPolicy({ worktreeRoot: root, access: 'read', allowedPaths: ['src/**'], blockedPaths: [], requiredCommands: [] });
|
|
23
|
+
const read = await policy.call('local_read', { path: 'src/probe.txt' });
|
|
24
|
+
if (read?.content !== PROBE)
|
|
25
|
+
throw new Error('local_read returned different content');
|
|
26
|
+
tool = 'local_search';
|
|
27
|
+
const search = await policy.call('local_search', { path: 'src/probe.txt', query: 'probe' });
|
|
28
|
+
if (!Array.isArray(search?.matches) || search.matches.length !== 1)
|
|
29
|
+
throw new Error('local_search returned different matches');
|
|
30
|
+
return { status: 'passed', node };
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
34
|
+
const result = { status: 'failed', tool, detail, node };
|
|
35
|
+
if (detail.startsWith('LOCAL_SANDBOX_UNAVAILABLE')) {
|
|
36
|
+
const measured = await runSandboxedMeasured({ executable: node, argv: ['--version'], readFiles: [], timeoutMs: 10000 });
|
|
37
|
+
result.sandbox = { status: measured.status, ...(measured.reason ? { reason: measured.reason } : {}), exit_code: measured.exit_code };
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await rm(root, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { LOCAL_TOOL_INVENTORIES, localToolNames } from './local-tool-policy.js';
|
|
5
5
|
const discoveryScopes = new WeakMap();
|
|
6
6
|
export function localDiscoveryScopeMatches(scope, endpoint, token) {
|
|
7
7
|
if (!scope || typeof scope !== 'object')
|
|
@@ -26,10 +26,20 @@ const schemas = {
|
|
|
26
26
|
local_edit: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string', description: 'The complete new contents of the entire file. Do not send a diff, patch, hunk, or edit instructions.' } }, required: ['path', 'content'], additionalProperties: false },
|
|
27
27
|
local_replace: { type: 'object', properties: { path: { type: 'string' }, old: { type: 'string', minLength: 1 }, new: { type: 'string' } }, required: ['path', 'old', 'new'], additionalProperties: false },
|
|
28
28
|
local_test: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'], additionalProperties: false },
|
|
29
|
+
// Every property is optional, so no `required`; the policy refuses a call with neither name nor text (DENIED_QUERY).
|
|
30
|
+
local_find: { type: 'object', properties: { path: { type: 'string', description: 'Checkout-relative directory to search below; omit it for the whole checkout.' }, name: { type: 'string', maxLength: 256, description: 'Literal, case-insensitive part of the checkout-relative file path.' }, text: { type: 'string', maxLength: 4096, description: 'Literal, case-sensitive part of one line.' }, max_results: { type: 'integer', minimum: 1, maximum: 200, description: 'Most matches returned; default 100.' } }, additionalProperties: false },
|
|
29
31
|
};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
export const LOCAL_TOOL_INVENTORY_IDS = Object.freeze(Object.keys(LOCAL_TOOL_INVENTORIES));
|
|
33
|
+
/** Same immutable-by-copy inventory used by native admission and MCP discovery. Without an id it is local-tools-v1, the
|
|
34
|
+
* inventory every caller not selected per job keeps. Descriptions and schemas are part of each inventory's fingerprint:
|
|
35
|
+
* changing them changes what the Gateway pins, so a change is a new inventory, never an edit of a listed one. */
|
|
36
|
+
export function localToolInventory(id = 'local-tools-v1') {
|
|
37
|
+
return localToolNames(id).map(name => ({ name, description: name === 'local_find' ? 'Find files inside the approved scope by name (a literal, case-insensitive part of the checkout-relative file path), by text (a literal, case-sensitive part of a line) or by both. path is a directory to search below; vendor/ and node_modules/ are skipped unless path is inside them. Returns matches sorted by path, each with line and text for a text match, the files scanned, and truncated: true when a search bound was reached. Then use local_search and local_read on a file found.' : name === 'local_read' ? 'Find locations with local_search first, then use local_read with start_line and end_line to read only the needed inclusive range (maximum 400 lines). Both range arguments are required together; invalid or beyond-EOF ranges are rejected, never widened. Returns content, start_line, end_line and total_lines for ranges; omit both for the entire file.' : name === 'local_edit' ? 'Replace the entire file at path with content (or create it). Read the current file first and include all unchanged text in content. This is not a diff or patch tool; patch headers and hunks are rejected.' : name === 'local_replace' ? 'Replace exactly one occurrence of old with new in the file at path. Read the file first; old must match exactly, including whitespace, and appear once. Prefer this for small changes; use local_edit only to write a whole file.' : `Scoped ${name} operation`, inputSchema: structuredClone(schemas[name]) }));
|
|
38
|
+
}
|
|
39
|
+
/** The inventory whose fingerprint under `hash` is `sha256`, or undefined: a job serves only an inventory its reviewed
|
|
40
|
+
* input names. Codex descriptors hash with `fingerprint`, Claude pins with `claudeCanonicalSha256` (same canonical JSON). */
|
|
41
|
+
export function localToolInventoryFor(sha256, hash) {
|
|
42
|
+
return typeof sha256 === 'string' && /^[a-f0-9]{64}$/.test(sha256) ? LOCAL_TOOL_INVENTORY_IDS.find(id => hash({ tools: localToolInventory(id) }) === sha256) : undefined;
|
|
33
43
|
}
|
|
34
44
|
/** Job-only JSON MCP endpoint; intentionally has no Gateway transport or discovery. */
|
|
35
45
|
export class LocalToolServer {
|
|
@@ -69,7 +79,9 @@ export class LocalToolServer {
|
|
|
69
79
|
discoveryScope = Object.freeze({ kind: 'local_discovery_scope' });
|
|
70
80
|
constructor(options) {
|
|
71
81
|
this.options = options;
|
|
82
|
+
this.tools = localToolNames(options.inventory);
|
|
72
83
|
}
|
|
84
|
+
tools;
|
|
73
85
|
revoke() { const first = !this.revoked; this.revoked = true; this.token = null; if (first)
|
|
74
86
|
this.observe('revoked'); this.options.policy.revoke?.(); }
|
|
75
87
|
observe(event) { try {
|
|
@@ -183,7 +195,7 @@ export class LocalToolServer {
|
|
|
183
195
|
if (rpc.method === 'tools/list') {
|
|
184
196
|
res.once('finish', () => { if (!this.revoked)
|
|
185
197
|
this.observe('inventory_response'); });
|
|
186
|
-
send({ tools: localToolInventory() });
|
|
198
|
+
send({ tools: localToolInventory(this.options.inventory) });
|
|
187
199
|
return;
|
|
188
200
|
}
|
|
189
201
|
if (rpc.method !== 'tools/call' || !rpc.params || typeof rpc.params !== 'object' || Array.isArray(rpc.params)) {
|
|
@@ -195,7 +207,7 @@ export class LocalToolServer {
|
|
|
195
207
|
return;
|
|
196
208
|
}
|
|
197
209
|
const params = rpc.params;
|
|
198
|
-
if (!
|
|
210
|
+
if (typeof params.name !== 'string' || !this.tools.includes(params.name)) {
|
|
199
211
|
reply(403, { error: 'Forbidden tool' });
|
|
200
212
|
return;
|
|
201
213
|
}
|
|
@@ -12,6 +12,48 @@ if (input.tool === 'local_replace') {
|
|
|
12
12
|
if (typeof input.new !== 'string' || Buffer.byteLength(input.new) > 1048576) throw Error('DENIED_CONTENT');
|
|
13
13
|
if (isPatchContent(input.new)) throw Error('DENIED_PATCH_CONTENT');
|
|
14
14
|
}
|
|
15
|
+
// local_find: only the host's candidate list, each [path, dev, ino] as the host saw it. A file that fails a local_read
|
|
16
|
+
// check, has a NUL byte in its first 8 KiB or cannot be read here is skipped, not reported.
|
|
17
|
+
const findText = (input) => {
|
|
18
|
+
const deadline = Date.now() + input.budget_ms, buffer = Buffer.alloc(1048577), matches = [];
|
|
19
|
+
let scanned = 0, size = 0, truncated = false;
|
|
20
|
+
const content = ([file, dev, ino]) => {
|
|
21
|
+
try {
|
|
22
|
+
let current = '';
|
|
23
|
+
for (const part of file.split('/').filter(Boolean)) {
|
|
24
|
+
current += '/' + part;
|
|
25
|
+
if (fs.lstatSync(current).isSymbolicLink()) return null;
|
|
26
|
+
}
|
|
27
|
+
const before = fs.lstatSync(file, {bigint: true});
|
|
28
|
+
if (!before.isFile() || before.nlink !== 1n || before.size > 1048576n || String(before.dev) !== dev || String(before.ino) !== ino) return null;
|
|
29
|
+
const fd = fs.openSync(file, fs.constants.O_NOFOLLOW | fs.constants.O_RDONLY);
|
|
30
|
+
try {
|
|
31
|
+
const actual = fs.fstatSync(fd, {bigint: true}), final = fs.lstatSync(file, {bigint: true});
|
|
32
|
+
if (actual.ino !== before.ino || actual.dev !== before.dev || final.ino !== before.ino || final.dev !== before.dev || actual.nlink !== 1n) return null;
|
|
33
|
+
const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
34
|
+
if (count > 1048576 || buffer.subarray(0, Math.min(count, 8192)).includes(0)) return null;
|
|
35
|
+
return buffer.subarray(0, count).toString('utf8');
|
|
36
|
+
} finally { fs.closeSync(fd); }
|
|
37
|
+
} catch { return null; }
|
|
38
|
+
};
|
|
39
|
+
files: for (let index = 0; index < input.files.length; index++) {
|
|
40
|
+
if (Date.now() >= deadline) { truncated = true; break; }
|
|
41
|
+
const data = content(input.files[index]);
|
|
42
|
+
if (data === null) continue;
|
|
43
|
+
scanned++;
|
|
44
|
+
const lines = data.split('\n');
|
|
45
|
+
for (let line = 0; line < lines.length; line++) {
|
|
46
|
+
if (!lines[line].includes(input.text)) continue;
|
|
47
|
+
const match = [index, line + 1, lines[line].slice(0, 300)];
|
|
48
|
+
size += JSON.stringify(match).length + 1;
|
|
49
|
+
if (matches.length >= input.max_results || size > 65536) { truncated = true; break files; }
|
|
50
|
+
matches.push(match);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {matches, scanned, truncated};
|
|
54
|
+
};
|
|
55
|
+
if (input.tool === 'local_find') process.stdout.write(JSON.stringify(findText(input)));
|
|
56
|
+
else {
|
|
15
57
|
try {
|
|
16
58
|
const readRange = input.tool === 'local_read' ? validateLocalReadRange(input) : undefined;
|
|
17
59
|
let current = '';
|
|
@@ -90,4 +132,5 @@ try {
|
|
|
90
132
|
if (!rangeDenied && !replaceDenied) throw error;
|
|
91
133
|
process.stdout.write(JSON.stringify({denied: error.message}));
|
|
92
134
|
}
|
|
135
|
+
}
|
|
93
136
|
`;
|
|
@@ -1,4 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
const localMcp = (names) => Object.freeze(names.map(name => `mcp__ai_local__${name}`));
|
|
2
|
+
const V1_TOOLS = ["local_read", "local_search", "local_edit", "local_replace", "local_test"];
|
|
3
|
+
/** Job-local MCP names of each tool inventory (local-tool-policy.ts LOCAL_TOOL_INVENTORIES, Work Decision f43e7aa0). The
|
|
4
|
+
* hook script does not load the policy module, so a test keeps the two equal. A job's policy allows only its own. */
|
|
5
|
+
export const CLAUDE_LOCAL_MCP_INVENTORIES = Object.freeze({
|
|
6
|
+
"local-tools-v1": localMcp(V1_TOOLS), "local-tools-v2": localMcp([...V1_TOOLS, "local_find"])
|
|
7
|
+
});
|
|
8
|
+
/** The local-tools-v1 names, what a job without a named inventory runs. */
|
|
9
|
+
export const CLAUDE_LOCAL_MCP_TOOLS = CLAUDE_LOCAL_MCP_INVENTORIES["local-tools-v1"];
|
|
10
|
+
const KNOWN_LOCAL_MCP_TOOLS = new Set(Object.values(CLAUDE_LOCAL_MCP_INVENTORIES).flat());
|
|
11
|
+
/** The names of one inventory; an unknown id fails closed. */
|
|
12
|
+
export function claudeLocalMcpTools(id = "local-tools-v1") {
|
|
13
|
+
if (!Object.hasOwn(CLAUDE_LOCAL_MCP_INVENTORIES, id))
|
|
14
|
+
throw new Error("DENIED_TOOL_INVENTORY");
|
|
15
|
+
return CLAUDE_LOCAL_MCP_INVENTORIES[id];
|
|
16
|
+
}
|
|
2
17
|
function allow(reasonCode) {
|
|
3
18
|
return {
|
|
4
19
|
hookSpecificOutput: {
|
|
@@ -30,6 +45,6 @@ export function evaluateHookEvent(input, policy) {
|
|
|
30
45
|
if (typeof toolName !== "string" || toolName.length === 0) {
|
|
31
46
|
return deny("DENIED_MALFORMED_INPUT");
|
|
32
47
|
}
|
|
33
|
-
return
|
|
48
|
+
return KNOWN_LOCAL_MCP_TOOLS.has(toolName) && policy.allowedMcpTools.includes(toolName)
|
|
34
49
|
? allow("ALLOWED_LOCAL_TOOL") : deny("DENIED_TOOL_NOT_ALLOWED");
|
|
35
50
|
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
const execFileAsync = promisify(execFile);
|
|
4
|
+
/** The employee's own Git environment with prompts off: a job's Git never waits for a person. */
|
|
5
|
+
export function gitEnvironment() {
|
|
6
|
+
return { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
|
7
|
+
}
|
|
4
8
|
export class GitClient {
|
|
5
9
|
async run(args, options = {}) {
|
|
6
10
|
const result = await execFileAsync("git", args, {
|
|
7
11
|
cwd: options.cwd,
|
|
8
12
|
encoding: "utf8",
|
|
9
13
|
maxBuffer: 10 * 1024 * 1024,
|
|
10
|
-
env:
|
|
14
|
+
env: gitEnvironment(),
|
|
11
15
|
});
|
|
12
16
|
return { stdout: result.stdout, stderr: result.stderr };
|
|
13
17
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { gitEnvironment } from "./git-client.js";
|
|
3
|
+
export class RepositoryAccessError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code) {
|
|
6
|
+
super(code === "REPOSITORY_ACCESS_DENIED" ? "Git could not read the registered repository without a credential prompt" : "Git could not reach the registered repository");
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
// No credential (GIT_TERMINAL_PROMPT=0 refused to ask), a refused one, or an account without access to the repository.
|
|
11
|
+
const DENIED = [
|
|
12
|
+
/terminal prompts disabled/i, /could not read (?:username|password)/i, /authentication failed/i, /invalid credentials/i,
|
|
13
|
+
/permission denied \(publickey/i, /host key verification failed/i, /returned error: 40[13]\b/i,
|
|
14
|
+
/you may not have access to this repository/i, /repository '[^'\n]*' not found/i, /repository not found/i,
|
|
15
|
+
];
|
|
16
|
+
// Name resolution, routing or a connection that never completed; ssh and https word these differently.
|
|
17
|
+
const UNREACHABLE = [
|
|
18
|
+
/could not resolve host/i, /failed to connect to/i, /couldn't connect to server/i, /connection timed out/i,
|
|
19
|
+
/operation timed out/i, /connection refused/i, /network is unreachable/i,
|
|
20
|
+
];
|
|
21
|
+
/** The access code a failed Git command's stderr shows, or undefined for any other failure. */
|
|
22
|
+
export function repositoryAccessFailure(error) {
|
|
23
|
+
const stderr = typeof error === "object" && error !== null ? error.stderr : undefined;
|
|
24
|
+
if (typeof stderr !== "string" || stderr === "")
|
|
25
|
+
return undefined;
|
|
26
|
+
if (DENIED.some(pattern => pattern.test(stderr)))
|
|
27
|
+
return "REPOSITORY_ACCESS_DENIED";
|
|
28
|
+
if (UNREACHABLE.some(pattern => pattern.test(stderr)))
|
|
29
|
+
return "REPOSITORY_UNREACHABLE";
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `git ls-remote <url> HEAD` with the environment a job's clone uses, so a missing credential shows before a job runs.
|
|
34
|
+
* It runs in its own session, so neither Git nor ssh can open the terminal to ask, and the whole group ends at the
|
|
35
|
+
* deadline. It reads refs only and writes nothing.
|
|
36
|
+
*/
|
|
37
|
+
export function probeRepositoryAccess(providerUrl, options = {}) {
|
|
38
|
+
return new Promise(resolve => {
|
|
39
|
+
// "--" keeps a URL from ever reading as an option such as --upload-pack.
|
|
40
|
+
const child = spawn("git", ["ls-remote", "--exit-code", "--", providerUrl, "HEAD"], { detached: true, stdio: ["ignore", "ignore", "pipe"], env: gitEnvironment() });
|
|
41
|
+
let stderr = "", settled = false;
|
|
42
|
+
const finish = (result) => { if (settled)
|
|
43
|
+
return; settled = true; clearTimeout(deadline); resolve(result); };
|
|
44
|
+
const deadline = setTimeout(() => {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(-child.pid, "SIGKILL");
|
|
47
|
+
}
|
|
48
|
+
catch { /* The group already ended. */ }
|
|
49
|
+
finish({ status: "unreachable", code: "REPOSITORY_UNREACHABLE" });
|
|
50
|
+
}, options.timeoutMs ?? 30_000);
|
|
51
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => { if (stderr.length < 64 * 1024)
|
|
52
|
+
stderr += chunk; });
|
|
53
|
+
child.on("error", () => finish({ status: "failed" }));
|
|
54
|
+
child.on("close", code => {
|
|
55
|
+
// Exit 2 is --exit-code's "no HEAD": the credential worked.
|
|
56
|
+
if (code === 0 || code === 2)
|
|
57
|
+
return finish({ status: "readable" });
|
|
58
|
+
const access = repositoryAccessFailure({ stderr });
|
|
59
|
+
finish(access === "REPOSITORY_ACCESS_DENIED" ? { status: "denied", code: access } : access === "REPOSITORY_UNREACHABLE" ? { status: "unreachable", code: access } : { status: "failed" });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const KEY = /^[a-z0-9][a-z0-9._-]{0,99}$/;
|
|
64
|
+
// The Gateway's registered clone URLs: https, no account in the URL, nothing a shell or Git option parser could read.
|
|
65
|
+
const URL_PATTERN = /^https:\/\/[A-Za-z0-9.-]+\/[A-Za-z0-9._\/-]+$/;
|
|
66
|
+
/** The Gateway's list of Fix repositories from local_agent_runner_status, or undefined when this Gateway has none. */
|
|
67
|
+
export function fixRepositoryList(runner) {
|
|
68
|
+
const list = typeof runner === "object" && runner !== null ? runner.fix_repositories : undefined;
|
|
69
|
+
if (!Array.isArray(list))
|
|
70
|
+
return undefined;
|
|
71
|
+
return list.slice(0, 100).filter((entry) => typeof entry === "object" && entry !== null
|
|
72
|
+
&& typeof entry.repository_key === "string" && KEY.test(entry.repository_key)
|
|
73
|
+
&& typeof entry.provider_url === "string" && URL_PATTERN.test(entry.provider_url))
|
|
74
|
+
.map(entry => ({ repository_key: entry.repository_key, provider_url: entry.provider_url }));
|
|
75
|
+
}
|
|
76
|
+
/** Probe each asked repository the Gateway lists, at most four at a time, and say what to do about each failure. */
|
|
77
|
+
export async function checkRepositoryAccess(keys, listed, probe = probeRepositoryAccess) {
|
|
78
|
+
const checks = [];
|
|
79
|
+
for (let start = 0; start < keys.length; start += 4) {
|
|
80
|
+
checks.push(...await Promise.all(keys.slice(start, start + 4).map(async (key) => {
|
|
81
|
+
const url = listed.find(entry => entry.repository_key === key)?.provider_url;
|
|
82
|
+
if (url === undefined)
|
|
83
|
+
return { repository_key: key, status: "not_registered", action: `${key} has no reviewed Fix profile on the Gateway; check the repository key.` };
|
|
84
|
+
const result = await probe(url);
|
|
85
|
+
const check = `git ls-remote ${url} HEAD`;
|
|
86
|
+
switch (result.status) {
|
|
87
|
+
case "readable": return { repository_key: key, status: "readable" };
|
|
88
|
+
case "denied": return { repository_key: key, status: "denied", action: `Set up a Bitbucket login for git on this Mac so that this prints a commit without a prompt: ${check}` };
|
|
89
|
+
case "unreachable": return { repository_key: key, status: "unreachable", action: `Check the network or VPN, then run: ${check}` };
|
|
90
|
+
default: return { repository_key: key, status: "failed", action: `Run ${check} to see why git cannot read it.` };
|
|
91
|
+
}
|
|
92
|
+
})));
|
|
93
|
+
}
|
|
94
|
+
return checks;
|
|
95
|
+
}
|
|
@@ -5,6 +5,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
5
5
|
import { resolve, join, sep, dirname } from "node:path";
|
|
6
6
|
import { assertOwnedGitObjectStorage, RegisteredSubmoduleMaterializer } from './registered-submodules.js';
|
|
7
7
|
import { isP1WorkingBranch } from '../publication/git-snapshot.js';
|
|
8
|
+
import { repositoryAccessFailure, RepositoryAccessError } from './repository-access.js';
|
|
8
9
|
export class WorkspaceManager {
|
|
9
10
|
deps;
|
|
10
11
|
submodules;
|
|
@@ -84,19 +85,26 @@ export class WorkspaceManager {
|
|
|
84
85
|
return this.deps.git.run([...(attempt ? ['--no-replace-objects'] : []), ...args]);
|
|
85
86
|
};
|
|
86
87
|
if (await this.exists(mirrorPath)) {
|
|
87
|
-
|
|
88
|
+
// The stored URL, not `remote get-url`: that expands the employee's url.<x>.insteadOf, so a Mac that reaches the
|
|
89
|
+
// registered https URL over SSH would fail every job after its first clone (Work Decision 2ae1acc1).
|
|
90
|
+
const remote = (await managedGit(["--git-dir", mirrorPath, "config", "--get", "remote.origin.url"])).stdout.trim();
|
|
88
91
|
if (remote !== input.providerUrl)
|
|
89
92
|
throw new Error("Managed repository remote does not match the registered provider");
|
|
90
93
|
}
|
|
91
94
|
else {
|
|
92
|
-
|
|
95
|
+
try {
|
|
96
|
+
await managedGit(["clone", "--mirror", input.providerUrl, mirrorPath]);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
throw this.accessError(error) ?? error;
|
|
100
|
+
}
|
|
93
101
|
}
|
|
94
102
|
try {
|
|
95
103
|
await managedGit(["--git-dir", mirrorPath, "fetch", "--prune", "origin", input.baseCommitSha]);
|
|
96
104
|
await managedGit(["--git-dir", mirrorPath, "cat-file", "-e", `${input.baseCommitSha}^{commit}`]);
|
|
97
105
|
}
|
|
98
|
-
catch {
|
|
99
|
-
throw new Error(`Repository revision ${input.baseCommitSha} is unavailable`);
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw this.accessError(error) ?? new Error(`Repository revision ${input.baseCommitSha} is unavailable`);
|
|
100
108
|
}
|
|
101
109
|
if (!await this.exists(workspacePath)) {
|
|
102
110
|
await managedGit(["--git-dir", mirrorPath, "worktree", "add", "--detach", workspacePath, input.baseCommitSha]);
|
|
@@ -190,6 +198,11 @@ export class WorkspaceManager {
|
|
|
190
198
|
await handle.close();
|
|
191
199
|
}
|
|
192
200
|
}
|
|
201
|
+
/** A credential or network failure keeps its code (Work Item 6f9e64ae); anything else stays the caller's error. */
|
|
202
|
+
accessError(error) {
|
|
203
|
+
const code = repositoryAccessFailure(error);
|
|
204
|
+
return code === undefined ? undefined : new RepositoryAccessError(code);
|
|
205
|
+
}
|
|
193
206
|
assertContained(root, candidate) {
|
|
194
207
|
const resolved = resolve(candidate);
|
|
195
208
|
if (resolved !== root && !resolved.startsWith(`${root}${sep}`))
|