mtech-ai-local-runner 0.1.29 → 0.1.31
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 +23 -2
- package/dist/src/adapters/claude-code/adapter.js +13 -0
- package/dist/src/adapters/codex/adapter.js +16 -0
- package/dist/src/adapters/codex/exec-launch-host.js +37 -1
- package/dist/src/cli.js +23 -0
- package/dist/src/config.js +4 -4
- package/dist/src/daemon/execution-coordinator.js +74 -2
- package/dist/src/daemon/runtime.js +18 -2
- package/dist/src/gateway-client.js +67 -0
- package/dist/src/platform/macos/launchd.js +13 -2
- package/dist/src/process/node-process-group-host.js +36 -1
- package/dist/src/publication/candidate.js +95 -0
- package/dist/src/publication/execution-host.js +55 -0
- package/dist/src/publication/git-credential-broker.js +150 -0
- package/dist/src/publication/git-credential-helper.js +29 -0
- package/dist/src/publication/git-snapshot.js +321 -0
- package/dist/src/publication/git-transport.js +156 -0
- package/dist/src/publication/installed-host.js +90 -0
- package/dist/src/publication/isolated-verifier.js +67 -0
- package/dist/src/publication/journal.js +87 -0
- package/dist/src/publication/production-host.js +151 -0
- package/dist/src/publication/project-registration.js +49 -0
- package/dist/src/publication/publisher.js +166 -0
- package/dist/src/publication/recovery.js +53 -0
- package/dist/src/publication/runtime-release.js +91 -0
- package/dist/src/safety/command-policy-core.js +124 -0
- package/dist/src/safety/command-policy.js +1 -124
- package/dist/src/safety/job-tool-policy.js +9 -0
- package/dist/src/safety/local-read-observations.js +17 -0
- package/dist/src/safety/local-tool-policy.js +1 -1
- package/dist/src/safety/local-tool-server.js +36 -1
- package/dist/src/workspace/workspace-manager.js +14 -0
- package/package.json +3 -2
- package/verification/README.md +63 -0
- package/verification/runner-policy.cjs +24 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
5
|
+
export function protocolHash(value) {
|
|
6
|
+
const canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, c]) => [k, canonical(c)])) : v;
|
|
7
|
+
return createHash('sha256').update(JSON.stringify(canonical(value)).replace(/[\u007f-\uffff]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
const denied = () => new Error('CANDIDATE_REJECTED');
|
|
10
|
+
function freeze(v) { if (v && typeof v === 'object') {
|
|
11
|
+
for (const c of Object.values(v))
|
|
12
|
+
freeze(c);
|
|
13
|
+
Object.freeze(v);
|
|
14
|
+
} return v; }
|
|
15
|
+
const sensitive = (s) => /^(?:\.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(s);
|
|
16
|
+
const validPath = (p) => typeof p === 'string' && p.length > 0 && !p.includes('\0') && !p.includes('\\') && !path.isAbsolute(p) && p.split('/').every(s => s !== '.' && s !== '..' && s !== '' && !sensitive(s));
|
|
17
|
+
function matches(file, scope) { if (scope.endsWith('/**')) {
|
|
18
|
+
const dir = scope.slice(0, -3);
|
|
19
|
+
return validPath(dir) && (file === dir || file.startsWith(dir + '/'));
|
|
20
|
+
} return validPath(scope) && file === scope; }
|
|
21
|
+
/** Object identity is host authority; serializing an evidence envelope cannot mint it.
|
|
22
|
+
* This is a verification foundation, not host adapter registration or a Gateway attestation. */
|
|
23
|
+
export class CandidateVerifier {
|
|
24
|
+
options;
|
|
25
|
+
issued = new WeakSet();
|
|
26
|
+
constructor(options) { this.options = { ...options, binding: freeze(structuredClone(options.binding)), commands: freeze(structuredClone(options.commands)), allowedPaths: [...options.allowedPaths], blockedPaths: [...options.blockedPaths] }; }
|
|
27
|
+
async snapshot() {
|
|
28
|
+
const o = this.options, b = o.binding;
|
|
29
|
+
if (!await o.current() || !Number.isSafeInteger(b.generation) || b.generation < 1 || !Number.isSafeInteger(o.maxFiles) || o.maxFiles < 1 || !Number.isSafeInteger(o.maxDiffBytes) || o.maxDiffBytes < 1 || !Number.isSafeInteger(o.maxFileBytes) || o.maxFileBytes < 1 || Object.values(b).some(v => v === '') || ![b.contract_fingerprint, b.task_package_fingerprint, b.launch_fingerprint].every(v => /^[a-f0-9]{64}$/.test(v)) || !/^[a-f0-9]{40}$/.test(b.base_commit_sha) || b.working_branch !== `ai-work/local/${b.attempt_id}` || !/^[A-Za-z0-9-]+$/.test(b.attempt_id) || !path.isAbsolute(o.root) || await realpath(o.root) !== o.root)
|
|
30
|
+
throw denied();
|
|
31
|
+
const observation = structuredClone(await o.observe());
|
|
32
|
+
if (observation.base_commit_sha !== b.base_commit_sha || observation.head_commit_sha !== b.base_commit_sha || observation.working_branch !== b.working_branch || Buffer.byteLength(observation.diff) > o.maxDiffBytes || !observation.diff.length || !observation.changed_files.length || observation.changed_files.length > o.maxFiles || new Set(observation.changed_files.map(f => f.path)).size !== observation.changed_files.length)
|
|
33
|
+
throw denied();
|
|
34
|
+
const entries = [];
|
|
35
|
+
for (const f of [...observation.changed_files].sort((a, b) => a.path < b.path ? -1 : 1)) {
|
|
36
|
+
if (!validPath(f.path) || !['added', 'modified', 'deleted'].includes(f.status) || !o.allowedPaths.some(s => matches(f.path, s)) || o.blockedPaths.some(s => s === '.env.*' ? f.path.startsWith('.env.') : matches(f.path, s)))
|
|
37
|
+
throw denied();
|
|
38
|
+
let current = o.root;
|
|
39
|
+
const parts = f.path.split('/');
|
|
40
|
+
for (let i = 0; i < parts.length; i++) {
|
|
41
|
+
current = path.join(current, parts[i]);
|
|
42
|
+
try {
|
|
43
|
+
const stat = await lstat(current);
|
|
44
|
+
if ((i === parts.length - 1 && stat.size > o.maxFileBytes) || stat.isSymbolicLink() || (i < parts.length - 1 && !stat.isDirectory()) || (i === parts.length - 1 && f.status === 'deleted'))
|
|
45
|
+
throw denied();
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
if (e.code === 'ENOENT' && i === parts.length - 1 && f.status === 'deleted')
|
|
49
|
+
break;
|
|
50
|
+
throw denied();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
entries.push({ ...f, content_sha256: f.status === 'deleted' ? null : await measureCommandFile(path.join(o.root, f.path)) });
|
|
54
|
+
}
|
|
55
|
+
if (!o.commands.length || new Set(o.commands.map(c => c.id)).size !== o.commands.length || o.commands.some(c => !c.id || !path.isAbsolute(c.executable) || !c.integrity || !c.argv.every(a => typeof a === 'string')))
|
|
56
|
+
throw denied();
|
|
57
|
+
const commands = o.commands.map(c => ({ id: c.id, fingerprint: protocolHash(c) }));
|
|
58
|
+
const diff_hash = createHash('sha256').update(observation.diff).digest('hex');
|
|
59
|
+
const changed_file_manifest_hash = protocolHash(entries.map(({ content_sha256: _, ...f }) => f));
|
|
60
|
+
const snapshot_fingerprint = protocolHash({ binding: b, entries, diff_hash, changed_file_manifest_hash, commands });
|
|
61
|
+
if (!await o.current())
|
|
62
|
+
throw denied();
|
|
63
|
+
return freeze({ snapshot_fingerprint, changed_file_manifest_hash, diff_hash, commands, changed_files: observation.changed_files, diff_bytes: Buffer.byteLength(observation.diff) });
|
|
64
|
+
}
|
|
65
|
+
async verify() {
|
|
66
|
+
try {
|
|
67
|
+
const snapshot = await this.snapshot(), outcomes = structuredClone(await this.options.verify(snapshot));
|
|
68
|
+
if (outcomes.length !== snapshot.commands.length || new Set(outcomes.map(r => r.command_id)).size !== outcomes.length || snapshot.commands.some(c => !outcomes.some(r => r.command_id === c.id && r.command_fingerprint === c.fingerprint && r.snapshot_fingerprint === snapshot.snapshot_fingerprint && r.status === 'passed')) || protocolHash(await this.snapshot()) !== protocolHash(snapshot))
|
|
69
|
+
throw denied();
|
|
70
|
+
const b = this.options.binding, verification_fingerprint = protocolHash({ binding: b, snapshot_fingerprint: snapshot.snapshot_fingerprint, outcomes: [...outcomes].sort((a, b) => a.command_id < b.command_id ? -1 : 1) });
|
|
71
|
+
const evidence = { ...b, ...snapshot, verification_fingerprint, verification_status: 'passed', snapshot_reference: { kind: 'runner_local_snapshot', fingerprint: snapshot.snapshot_fingerprint, runner_id: b.runner_id, job_id: b.job_id, generation: b.generation } };
|
|
72
|
+
const { commands: _, ...wire } = evidence;
|
|
73
|
+
const candidate = freeze({ evidence: { ...wire, candidate_fingerprint: protocolHash(wire) }, testReceipts: outcomes });
|
|
74
|
+
this.issued.add(candidate);
|
|
75
|
+
return candidate;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw denied();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async assertCurrent(candidate) {
|
|
82
|
+
try {
|
|
83
|
+
if (!this.issued.has(candidate) || !await this.options.current())
|
|
84
|
+
throw denied();
|
|
85
|
+
if (this.options.assertRetainedSeal)
|
|
86
|
+
await this.options.assertRetainedSeal();
|
|
87
|
+
else if (candidate.evidence.snapshot_fingerprint !== (await this.snapshot()).snapshot_fingerprint)
|
|
88
|
+
throw denied();
|
|
89
|
+
return candidate.evidence;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw denied();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { protocolHash } from './candidate.js';
|
|
2
|
+
import { PublicationOutcomeUnknownError } from './publisher.js';
|
|
3
|
+
/** Binds the real candidate/publisher pipeline to coordinator ownership and keeps
|
|
4
|
+
* its lease alive through terminal publication. Model reports are never input. */
|
|
5
|
+
export function createLocalFixExecutionHost(composition) {
|
|
6
|
+
const registration = composition.registration ? structuredClone(composition.registration) : undefined;
|
|
7
|
+
return { prepare: async (input) => {
|
|
8
|
+
try {
|
|
9
|
+
const scope = input.job.taskPackage.local_fix_scope;
|
|
10
|
+
if (!registration || !scope)
|
|
11
|
+
throw Error();
|
|
12
|
+
for (const field of ['host_release_sha256', 'test_isolation_sha256', 'publisher_release_sha256'])
|
|
13
|
+
if (!/^[a-f0-9]{64}$/.test(registration.receipt[field]) || registration.receipt[field] !== scope[field])
|
|
14
|
+
throw Error();
|
|
15
|
+
const ids = scope.required_commands, entries = scope.registered_tests;
|
|
16
|
+
if (!Array.isArray(ids) || !ids.length || new Set(ids).size !== ids.length || ids.length !== registration.commands.length)
|
|
17
|
+
throw Error();
|
|
18
|
+
for (const c of registration.commands) {
|
|
19
|
+
if (c.candidateReadPaths?.some(p => !Array.isArray(scope.allowed_paths) || !scope.allowed_paths.includes(p) || Object.hasOwn(c.integrity.readFiles, p)))
|
|
20
|
+
throw Error();
|
|
21
|
+
const e = entries[c.id];
|
|
22
|
+
if (!ids.includes(c.id) || !e || e.review_status !== 'reviewed' || protocolHash(c) !== protocolHash({ id: c.id, executable: e.executable, argv: e.argv, integrity: { executableSha256: e.executable_sha256, readFiles: e.read_files }, ...('candidate_read_paths' in e ? { candidateReadPaths: e.candidate_read_paths } : {}) }))
|
|
23
|
+
throw Error();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw Error('LOCAL_FIX_HOST_REGISTRATION_REJECTED');
|
|
28
|
+
}
|
|
29
|
+
const session = await composition.prepare(input);
|
|
30
|
+
let closed = false, closing, finalization;
|
|
31
|
+
return {
|
|
32
|
+
finalize() {
|
|
33
|
+
if (closed)
|
|
34
|
+
return Promise.reject(new Error('LOCAL_FIX_SESSION_CLOSED'));
|
|
35
|
+
if (!finalization)
|
|
36
|
+
finalization = (async () => {
|
|
37
|
+
const candidate = await session.verifier.verify();
|
|
38
|
+
if (closed)
|
|
39
|
+
throw Error('LOCAL_FIX_SESSION_CLOSED');
|
|
40
|
+
let publication;
|
|
41
|
+
try {
|
|
42
|
+
publication = await session.publisher.publish(candidate);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new PublicationOutcomeUnknownError();
|
|
46
|
+
}
|
|
47
|
+
const e = candidate.evidence;
|
|
48
|
+
return { publication_id: publication.publication_id, status: 'completed', candidate_fingerprint: e.candidate_fingerprint, snapshot_fingerprint: e.snapshot_fingerprint, verification_fingerprint: e.verification_fingerprint, commit_sha: publication.committed_sha, working_branch: e.working_branch, pr_url: publication.pull_request_url, pull_request_provider_id: publication.pull_request_id, changed_files: e.changed_files.map(f => f.path), diff_hash: e.diff_hash, changed_file_manifest_hash: e.changed_file_manifest_hash };
|
|
49
|
+
})();
|
|
50
|
+
return finalization;
|
|
51
|
+
},
|
|
52
|
+
close() { closed = true; return closing ??= session.close(); },
|
|
53
|
+
};
|
|
54
|
+
} };
|
|
55
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { lstat, realpath, readFile, mkdtemp, chmod, rm } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
6
|
+
const denied = () => Error('GIT_CREDENTIAL_SCOPE_REJECTED');
|
|
7
|
+
export const publicationGitConfig = '[core]\nrepositoryformatversion = 0\nbare = false\nhooksPath = /dev/null\nfsmonitor = false\n';
|
|
8
|
+
export function strictPublicationOrigin(origin) {
|
|
9
|
+
const url = new URL(origin);
|
|
10
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash || url.href !== origin || !/^\/[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\.git$/.test(url.pathname) || url.pathname.includes('..') || !/^[a-z0-9.-]+$/.test(url.hostname))
|
|
11
|
+
throw denied();
|
|
12
|
+
}
|
|
13
|
+
export async function assertPinnedGit(file) { if (!path.isAbsolute(file.executable) || await realpath(file.executable) !== file.executable || await measureCommandFile(file.executable) !== file.sha256)
|
|
14
|
+
throw denied(); }
|
|
15
|
+
export async function assertPrivateCheckout(checkout, privateRoot) {
|
|
16
|
+
if (!path.isAbsolute(privateRoot) || await realpath(privateRoot) !== privateRoot || !checkout.startsWith(privateRoot + '/') || await realpath(checkout) !== checkout)
|
|
17
|
+
throw denied();
|
|
18
|
+
for (const dir of [privateRoot, checkout, path.join(checkout, '.git')]) {
|
|
19
|
+
const s = await lstat(dir);
|
|
20
|
+
if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) || (process.getuid && s.uid !== process.getuid()))
|
|
21
|
+
throw denied();
|
|
22
|
+
}
|
|
23
|
+
if (await readFile(path.join(checkout, '.git', 'config'), 'utf8') !== publicationGitConfig)
|
|
24
|
+
throw denied();
|
|
25
|
+
}
|
|
26
|
+
/** No inherited auth, proxy, Git/SSH config or helper environment. */
|
|
27
|
+
export function publicationGitEnvironment() { return { PATH: '/usr/bin:/bin', HOME: '/nonexistent', LANG: 'C', LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_ATTR_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0', GIT_NO_REPLACE_OBJECTS: '1', GIT_OPTIONAL_LOCKS: '0' }; }
|
|
28
|
+
export const publicationGitArgs = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.attributesFile=/dev/null', '-c', 'core.excludesFile=/dev/null', '-c', 'core.logAllRefUpdates=false', '-c', 'credential.helper=', '-c', 'http.followRedirects=false', '-c', 'protocol.allow=never', '-c', 'protocol.https.allow=always'];
|
|
29
|
+
/** Private one-operation Unix socket serves credentials only to the pinned Git
|
|
30
|
+
* helper for the registered HTTPS host and repository path. Secrets never enter
|
|
31
|
+
* argv/environment/disk. Owner-only directories/socket protect other UIDs; the
|
|
32
|
+
* required model/tool shutdown and OS confinement protect against the same UID. */
|
|
33
|
+
export class ScopedGitCredentialBroker {
|
|
34
|
+
options;
|
|
35
|
+
constructor(options) { this.options = { ...options, git: { ...options.git }, remoteHelper: { ...options.remoteHelper }, node: options.node ? { ...options.node } : undefined, credentialHelper: options.credentialHelper ? { ...options.credentialHelper } : undefined }; }
|
|
36
|
+
async validate(scope) {
|
|
37
|
+
const o = this.options;
|
|
38
|
+
strictPublicationOrigin(o.origin);
|
|
39
|
+
if (scope.origin !== o.origin || !/^ai-work\/local\/[A-Za-z0-9-]+$/.test(o.branch) || scope.ref !== `refs/heads/${o.branch}` || !/^[a-f0-9]{40}$/.test(scope.sha) || !await o.current() || !o.privateRoot)
|
|
40
|
+
throw denied();
|
|
41
|
+
await assertPrivateCheckout(scope.checkout, o.privateRoot);
|
|
42
|
+
if (await readFile(path.join(scope.checkout, '.git', 'HEAD'), 'utf8') !== `ref: ${scope.ref}\n` || (await readFile(path.join(scope.checkout, '.git', scope.ref), 'utf8')).trim() !== scope.sha)
|
|
43
|
+
throw denied();
|
|
44
|
+
await assertPinnedGit(o.git);
|
|
45
|
+
await assertPinnedGit(o.remoteHelper);
|
|
46
|
+
if (!o.node || !o.credentialHelper)
|
|
47
|
+
throw denied();
|
|
48
|
+
await assertPinnedGit(o.node);
|
|
49
|
+
await assertPinnedGit(o.credentialHelper);
|
|
50
|
+
if (await realpath(path.join(path.dirname(o.remoteHelper.executable), 'git-remote-https')) !== o.remoteHelper.executable)
|
|
51
|
+
throw denied();
|
|
52
|
+
}
|
|
53
|
+
async run(scope, read) {
|
|
54
|
+
let directory, server;
|
|
55
|
+
const sockets = new Set();
|
|
56
|
+
try {
|
|
57
|
+
const frozen = Object.freeze({ ...scope });
|
|
58
|
+
await this.validate(frozen);
|
|
59
|
+
const o = this.options, url = new URL(o.origin);
|
|
60
|
+
directory = await mkdtemp(path.join(o.privateRoot, 'auth-'));
|
|
61
|
+
await chmod(directory, 0o700);
|
|
62
|
+
const socketPath = path.join(directory, 's');
|
|
63
|
+
let used = false;
|
|
64
|
+
server = createServer(socket => {
|
|
65
|
+
sockets.add(socket);
|
|
66
|
+
socket.on('close', () => sockets.delete(socket));
|
|
67
|
+
let body = '';
|
|
68
|
+
socket.setTimeout(10000, () => socket.destroy());
|
|
69
|
+
socket.on('error', () => { });
|
|
70
|
+
socket.on('data', chunk => {
|
|
71
|
+
body += chunk.toString();
|
|
72
|
+
if (body.length > 16384) {
|
|
73
|
+
socket.destroy();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!body.endsWith('\n'))
|
|
77
|
+
return;
|
|
78
|
+
socket.pause();
|
|
79
|
+
void (async () => {
|
|
80
|
+
try {
|
|
81
|
+
const fields = JSON.parse(body);
|
|
82
|
+
if (used || Object.keys(fields).some(k => !['protocol', 'host', 'path', 'username', 'wwwauth[]'].includes(k)) || fields.protocol !== 'https' || fields.host !== url.host || fields.path !== url.pathname.slice(1))
|
|
83
|
+
throw denied();
|
|
84
|
+
used = true;
|
|
85
|
+
await this.validate(frozen);
|
|
86
|
+
const credential = await o.authorization(frozen);
|
|
87
|
+
if (!credential.username || !credential.password || /[\r\n\0]/.test(credential.username + credential.password) || credential.username.length + credential.password.length > 16384)
|
|
88
|
+
throw denied();
|
|
89
|
+
await this.validate(frozen);
|
|
90
|
+
socket.end(JSON.stringify(credential));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
socket.destroy();
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
|
|
99
|
+
await chmod(socketPath, 0o600);
|
|
100
|
+
// Git parses helper strings using a shell; quote each trusted absolute path.
|
|
101
|
+
const quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
|
|
102
|
+
const helper = `!${quote(o.node.executable)} ${quote(o.credentialHelper.executable)} --local-fix-helper ${quote(socketPath)}`;
|
|
103
|
+
const args = [...publicationGitArgs, '-c', 'credential.useHttpPath=true', '-c', `credential.helper=${helper}`, ...(read ? ['ls-remote', '--refs', scope.origin, scope.ref] : ['push', '--porcelain', '--no-verify', scope.origin, `${scope.sha}:${scope.ref}`])];
|
|
104
|
+
const output = await new Promise((resolve, reject) => {
|
|
105
|
+
const child = spawn(o.git.executable, args, { cwd: scope.checkout, env: { ...publicationGitEnvironment(), GIT_EXEC_PATH: path.dirname(o.remoteHelper.executable) }, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
106
|
+
let stdout = '', bytes = 0, settled = false;
|
|
107
|
+
const fail = () => { if (!settled) {
|
|
108
|
+
settled = true;
|
|
109
|
+
try {
|
|
110
|
+
if (child.pid)
|
|
111
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
child.kill('SIGKILL');
|
|
115
|
+
}
|
|
116
|
+
reject(denied());
|
|
117
|
+
} };
|
|
118
|
+
const timeout = setTimeout(fail, 60000);
|
|
119
|
+
child.on('error', fail);
|
|
120
|
+
child.stdout.on('data', (chunk) => { bytes += chunk.length; if (bytes > 65536)
|
|
121
|
+
fail();
|
|
122
|
+
else
|
|
123
|
+
stdout += chunk.toString(); });
|
|
124
|
+
// Never forward stderr: a failing helper may echo credential material.
|
|
125
|
+
child.stderr.on('data', () => { });
|
|
126
|
+
child.on('close', code => { clearTimeout(timeout); if (!settled) {
|
|
127
|
+
settled = true;
|
|
128
|
+
code === 0 ? resolve(stdout) : reject(denied());
|
|
129
|
+
} });
|
|
130
|
+
});
|
|
131
|
+
await this.validate(frozen);
|
|
132
|
+
return output;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw denied();
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
for (const socket of sockets)
|
|
139
|
+
socket.destroy();
|
|
140
|
+
server?.close();
|
|
141
|
+
if (directory)
|
|
142
|
+
await rm(directory, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async push(scope) { await this.run(scope, false); }
|
|
146
|
+
async head(scope) { const output = await this.run(scope, true); if (!output.trim())
|
|
147
|
+
return null; const lines = output.trim().split('\n'); if (lines.length !== 1)
|
|
148
|
+
throw denied(); const match = /^([a-f0-9]{40})\t(.+)$/.exec(lines[0]); if (!match || match[2] !== scope.ref)
|
|
149
|
+
throw denied(); return match[1]; }
|
|
150
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
/** Dedicated child of pinned Git only. No ambient credential or environment lookup.
|
|
3
|
+
* Its private socket is passed as an argument; the socket name is not a secret. */
|
|
4
|
+
export async function runGitCredentialHelper(socketPath, operation, input = process.stdin, output = process.stdout) {
|
|
5
|
+
if (operation !== 'get')
|
|
6
|
+
return;
|
|
7
|
+
let body = '';
|
|
8
|
+
for await (const chunk of input) {
|
|
9
|
+
body += chunk.toString();
|
|
10
|
+
if (body.length > 16384)
|
|
11
|
+
throw Error('CREDENTIAL_REJECTED');
|
|
12
|
+
}
|
|
13
|
+
const fields = {};
|
|
14
|
+
for (const line of body.trim().split('\n')) {
|
|
15
|
+
const i = line.indexOf('=');
|
|
16
|
+
if (i < 1 || Object.hasOwn(fields, line.slice(0, i)))
|
|
17
|
+
throw Error('CREDENTIAL_REJECTED');
|
|
18
|
+
fields[line.slice(0, i)] = line.slice(i + 1);
|
|
19
|
+
}
|
|
20
|
+
const response = await new Promise((resolve, reject) => { const socket = connect(socketPath); let data = ''; socket.setTimeout(10000, () => socket.destroy(Error('timeout'))); socket.on('connect', () => socket.write(JSON.stringify(fields) + '\n')); socket.on('data', chunk => { data += chunk.toString(); if (data.length > 32768)
|
|
21
|
+
socket.destroy(Error('overflow')); }); socket.on('end', () => resolve(data)); socket.on('error', reject); });
|
|
22
|
+
const credential = JSON.parse(response);
|
|
23
|
+
if (!credential.username || !credential.password || /[\r\n\0]/.test(credential.username + credential.password))
|
|
24
|
+
throw Error('CREDENTIAL_REJECTED');
|
|
25
|
+
output.write(`username=${credential.username}\npassword=${credential.password}\n\n`);
|
|
26
|
+
}
|
|
27
|
+
if (process.argv[2] === '--local-fix-helper') {
|
|
28
|
+
runGitCredentialHelper(process.argv[3], process.argv[4]).catch(() => { process.exitCode = 1; });
|
|
29
|
+
}
|