560 lines
17 KiB
TypeScript
560 lines
17 KiB
TypeScript
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { tmpdir } from 'node:os';
|
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
import {
|
|
assessCredentialResult,
|
|
deriveBrainTarget,
|
|
evaluateBrainDoctor,
|
|
planBrainDoctorFix,
|
|
type BrainDoctorFinding,
|
|
type BrainDoctorObservation,
|
|
type CredentialAssessment,
|
|
} from './brain-store.js';
|
|
|
|
const COMMIT = /^[0-9a-f]{40}$/;
|
|
const GIT_OBJECT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
const MAX_PUBLISH_ENTRY_BYTES = 1024 * 1024;
|
|
|
|
export interface CommandRequest {
|
|
readonly program: 'git' | 'mosaic';
|
|
readonly args: readonly string[];
|
|
readonly cwd?: string;
|
|
readonly env: Readonly<Record<string, string>>;
|
|
readonly stdin?: Uint8Array;
|
|
}
|
|
|
|
export interface CommandResult {
|
|
readonly status: number;
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
}
|
|
|
|
export type CommandRunner = (request: CommandRequest) => CommandResult;
|
|
|
|
export const systemCommandRunner: CommandRunner = (request: CommandRequest): CommandResult => {
|
|
const result = spawnSync(request.program, request.args, {
|
|
cwd: request.cwd,
|
|
env: { ...process.env, ...request.env },
|
|
encoding: 'utf8',
|
|
maxBuffer: 1024 * 1024,
|
|
input: request.stdin,
|
|
});
|
|
return {
|
|
status: result.status ?? 127,
|
|
stdout: result.stdout ?? '',
|
|
stderr: result.stderr ?? result.error?.message ?? '',
|
|
};
|
|
};
|
|
|
|
export interface DoctorRuntimeReport {
|
|
readonly findings: readonly BrainDoctorFinding[];
|
|
readonly access: CredentialAssessment;
|
|
readonly refusalControl: {
|
|
readonly observed: boolean;
|
|
readonly reasonCode: string | null;
|
|
};
|
|
}
|
|
|
|
export interface BrainRefusalControlResult {
|
|
readonly observed: boolean;
|
|
readonly reasonCode: string | null;
|
|
readonly gitReasonCode: string;
|
|
readonly apiReasonCode: string;
|
|
}
|
|
|
|
export interface PublishEvidence {
|
|
readonly commit: string;
|
|
readonly remoteHead: string;
|
|
readonly reachable: boolean;
|
|
}
|
|
|
|
function commandEnv(identity: string): Readonly<Record<string, string>> {
|
|
return {
|
|
MOSAIC_GIT_IDENTITY: identity,
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
};
|
|
}
|
|
|
|
function integrationFailure(): CredentialAssessment {
|
|
return {
|
|
outcome: 'indeterminate',
|
|
exitCode: 30,
|
|
reasonCode: 'unexpected-provider-shape',
|
|
diagnostic: 'indeterminate: unexpected-provider-shape',
|
|
};
|
|
}
|
|
|
|
function runGit(run: CommandRunner, identity: string, args: readonly string[]): CommandResult {
|
|
return run({ program: 'git', args, env: commandEnv(identity) });
|
|
}
|
|
|
|
function runGitWithEnv(
|
|
run: CommandRunner,
|
|
identity: string,
|
|
args: readonly string[],
|
|
env: Readonly<Record<string, string>>,
|
|
stdin?: Uint8Array,
|
|
): CommandResult {
|
|
return run({ program: 'git', args, env: { ...commandEnv(identity), ...env }, stdin });
|
|
}
|
|
|
|
export function collectBrainDoctorReport(
|
|
input: {
|
|
readonly registrySource: string;
|
|
readonly targetGitUrl: string;
|
|
readonly brainNamespace: string;
|
|
readonly identity: string;
|
|
readonly root: string;
|
|
},
|
|
run: CommandRunner,
|
|
): DoctorRuntimeReport {
|
|
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
|
const validation = run({
|
|
program: 'mosaic',
|
|
args: [
|
|
'cred',
|
|
'validate',
|
|
input.identity,
|
|
'--estate',
|
|
target.estate,
|
|
'--host',
|
|
target.host,
|
|
'--repo',
|
|
target.repo,
|
|
'--require',
|
|
'write',
|
|
'--json',
|
|
],
|
|
env: commandEnv(input.identity),
|
|
});
|
|
let access = assessCredentialResult(validation.stdout, {
|
|
identity: input.identity,
|
|
estate: target.estate,
|
|
host: target.host,
|
|
repo: target.repo,
|
|
});
|
|
if (validation.status !== access.exitCode) access = integrationFailure();
|
|
|
|
const rootExists = existsSync(input.root);
|
|
let gitRepository = false;
|
|
let remote: string | null = null;
|
|
let branch: string | null = null;
|
|
let worktreeState: BrainDoctorObservation['worktreeState'] = 'unmeasurable';
|
|
if (rootExists) {
|
|
const repository = runGit(run, input.identity, [
|
|
'-C',
|
|
input.root,
|
|
'rev-parse',
|
|
'--is-inside-work-tree',
|
|
]);
|
|
gitRepository = repository.status === 0 && repository.stdout.trim() === 'true';
|
|
if (gitRepository) {
|
|
const remoteResult = runGit(run, input.identity, [
|
|
'-C',
|
|
input.root,
|
|
'remote',
|
|
'get-url',
|
|
'origin',
|
|
]);
|
|
const branchResult = runGit(run, input.identity, [
|
|
'-C',
|
|
input.root,
|
|
'branch',
|
|
'--show-current',
|
|
]);
|
|
const statusResult = runGit(run, input.identity, ['-C', input.root, 'status', '--porcelain']);
|
|
if (remoteResult.status === 0) remote = remoteResult.stdout.trim();
|
|
if (branchResult.status === 0) branch = branchResult.stdout.trim();
|
|
if (statusResult.status === 0) {
|
|
worktreeState = statusResult.stdout.trim().length > 0 ? 'dirty' : 'clean';
|
|
}
|
|
}
|
|
}
|
|
|
|
const observation: BrainDoctorObservation = {
|
|
rootExists,
|
|
gitRepository,
|
|
remote,
|
|
branch,
|
|
worktreeState,
|
|
access,
|
|
};
|
|
const refusalMarker = `refused reason=${access.reasonCode}`;
|
|
const refusalObserved =
|
|
validation.status === 10 &&
|
|
access.outcome === 'refused' &&
|
|
access.reasonCode === 'no-token-for-identity' &&
|
|
validation.stderr.includes(refusalMarker);
|
|
return {
|
|
findings: evaluateBrainDoctor(observation, target.cloneUrl),
|
|
access,
|
|
refusalControl: {
|
|
observed: refusalObserved,
|
|
reasonCode: refusalObserved ? access.reasonCode : null,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function collectBrainRefusalControl(
|
|
input: {
|
|
readonly registrySource: string;
|
|
readonly targetGitUrl: string;
|
|
readonly brainNamespace: string;
|
|
readonly refusalIdentity: string;
|
|
},
|
|
run: CommandRunner,
|
|
): BrainRefusalControlResult {
|
|
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(input.refusalIdentity)) {
|
|
return {
|
|
observed: false,
|
|
reasonCode: 'permission-evidence-disagrees',
|
|
gitReasonCode: 'invalid-control-identity',
|
|
apiReasonCode: 'invalid-control-identity',
|
|
};
|
|
}
|
|
const apiResult = run({
|
|
program: 'mosaic',
|
|
args: [
|
|
'cred',
|
|
'validate',
|
|
input.refusalIdentity,
|
|
'--estate',
|
|
target.estate,
|
|
'--host',
|
|
target.host,
|
|
'--repo',
|
|
target.repo,
|
|
'--require',
|
|
'write',
|
|
'--json',
|
|
],
|
|
env: commandEnv(input.refusalIdentity),
|
|
});
|
|
let api = assessCredentialResult(apiResult.stdout, {
|
|
identity: input.refusalIdentity,
|
|
estate: target.estate,
|
|
host: target.host,
|
|
repo: target.repo,
|
|
});
|
|
if (apiResult.status !== api.exitCode) api = integrationFailure();
|
|
|
|
const gitResult = runGit(run, input.refusalIdentity, ['ls-remote', target.cloneUrl, 'HEAD']);
|
|
const marker = /(?:^|\s)reason=([a-z0-9-]+)(?:\s|$)/.exec(gitResult.stderr)?.[1];
|
|
const stableRefusals = new Set([
|
|
'identity-required',
|
|
'estate-required',
|
|
'estate-host-mismatch',
|
|
'cross-estate-resolution',
|
|
'no-token-for-identity',
|
|
'tea-login-missing',
|
|
'tea-login-host-mismatch',
|
|
'provider-identity-mismatch',
|
|
'credential-rejected',
|
|
'permission-denied',
|
|
'organization-membership-required',
|
|
'team-membership-required',
|
|
]);
|
|
const gitReasonCode =
|
|
gitResult.status === 0
|
|
? 'transport-accepted'
|
|
: marker !== undefined && stableRefusals.has(marker) && gitResult.stdout.length === 0
|
|
? marker
|
|
: 'transport-indeterminate';
|
|
const observed =
|
|
api.outcome === 'refused' &&
|
|
gitReasonCode !== 'transport-accepted' &&
|
|
gitReasonCode !== 'transport-indeterminate' &&
|
|
gitReasonCode === api.reasonCode;
|
|
return {
|
|
observed,
|
|
reasonCode: observed ? api.reasonCode : 'permission-evidence-disagrees',
|
|
gitReasonCode,
|
|
apiReasonCode: api.reasonCode,
|
|
};
|
|
}
|
|
|
|
export function repairBrainDoctor(
|
|
input: {
|
|
readonly registrySource: string;
|
|
readonly targetGitUrl: string;
|
|
readonly brainNamespace: string;
|
|
readonly identity: string;
|
|
readonly root: string;
|
|
},
|
|
run: CommandRunner,
|
|
): DoctorRuntimeReport {
|
|
const target = deriveBrainTarget(input.registrySource, input.targetGitUrl, input.brainNamespace);
|
|
let report = collectBrainDoctorReport(input, run);
|
|
const actions = planBrainDoctorFix({
|
|
findings: report.findings,
|
|
target,
|
|
identity: input.identity,
|
|
root: input.root,
|
|
});
|
|
for (const action of actions) {
|
|
if (action.program === 'mosaic') {
|
|
run({ program: 'mosaic', args: action.args, env: commandEnv(input.identity) });
|
|
report = collectBrainDoctorReport(input, run);
|
|
if (report.access.outcome !== 'ok') return report;
|
|
continue;
|
|
}
|
|
const result = runGit(run, input.identity, action.args);
|
|
if (result.status !== 0) return collectBrainDoctorReport(input, run);
|
|
}
|
|
return collectBrainDoctorReport(input, run);
|
|
}
|
|
|
|
function requireSuccess(result: CommandResult, operation: string): void {
|
|
if (result.status !== 0) throw new Error(`${operation}-failed`);
|
|
}
|
|
|
|
function containedRelative(root: string, path: string): string {
|
|
if (isAbsolute(path) === false) throw new Error('brain-publish-path-must-be-absolute');
|
|
const absoluteRoot = resolve(root);
|
|
const absolutePath = resolve(path);
|
|
if (absolutePath === absoluteRoot || !absolutePath.startsWith(`${absoluteRoot}${sep}`)) {
|
|
throw new Error('brain-publish-path-escaped-root');
|
|
}
|
|
return relative(absoluteRoot, absolutePath).split(sep).join('/');
|
|
}
|
|
|
|
export function publishBrainPaths(
|
|
input: {
|
|
readonly root: string;
|
|
readonly identity: string;
|
|
readonly entries: readonly {
|
|
readonly path: string;
|
|
readonly content: Uint8Array;
|
|
}[];
|
|
readonly message: string;
|
|
},
|
|
run: CommandRunner,
|
|
): PublishEvidence {
|
|
if (input.entries.length === 0) throw new Error('brain-publish-paths-empty');
|
|
if (input.message.trim().length === 0) throw new Error('brain-publish-message-empty');
|
|
const entries = input.entries.map((entry) => {
|
|
if (entry.content.byteLength > MAX_PUBLISH_ENTRY_BYTES) {
|
|
throw new Error('brain-publish-entry-too-large');
|
|
}
|
|
return {
|
|
path: containedRelative(input.root, entry.path),
|
|
content: Uint8Array.from(entry.content),
|
|
};
|
|
});
|
|
const paths = entries.map((entry): string => entry.path);
|
|
if (new Set(paths).size !== paths.length) throw new Error('brain-publish-path-duplicate');
|
|
|
|
const readHead = (): string => {
|
|
const result = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'HEAD']);
|
|
requireSuccess(result, 'brain-git-read-commit');
|
|
const value = result.stdout.trim();
|
|
if (!COMMIT.test(value)) throw new Error('brain-git-commit-shape-invalid');
|
|
return value;
|
|
};
|
|
const verifyCommitIdentity = (commit: string): void => {
|
|
const result = runGit(run, input.identity, [
|
|
'-C',
|
|
input.root,
|
|
'show',
|
|
'-s',
|
|
'--format=%an%x00%ae%x00%cn%x00%ce',
|
|
commit,
|
|
]);
|
|
requireSuccess(result, 'brain-git-read-commit-identity');
|
|
const expectedEmail = `${input.identity}@fleet.mosaicstack.dev`;
|
|
const [author, authorEmail, committer, committerEmail] = result.stdout.trimEnd().split('\0');
|
|
if (
|
|
author !== input.identity ||
|
|
authorEmail !== expectedEmail ||
|
|
committer !== input.identity ||
|
|
committerEmail !== expectedEmail
|
|
) {
|
|
throw new Error('brain-git-commit-identity-mismatch');
|
|
}
|
|
};
|
|
const expectedObjects = new Map<string, string>();
|
|
const verifyCommitObjects = (commit: string): void => {
|
|
for (const [path, expected] of expectedObjects) {
|
|
const result = runGit(run, input.identity, [
|
|
'-C',
|
|
input.root,
|
|
'rev-parse',
|
|
`${commit}:${path}`,
|
|
]);
|
|
requireSuccess(result, 'brain-git-read-commit-object');
|
|
if (result.stdout.trim() !== expected) throw new Error('brain-git-commit-content-mismatch');
|
|
}
|
|
};
|
|
const reconcileRealIndex = (): void => {
|
|
for (const [path, objectId] of expectedObjects) {
|
|
requireSuccess(
|
|
runGit(run, input.identity, [
|
|
'-C',
|
|
input.root,
|
|
'update-index',
|
|
'--add',
|
|
'--cacheinfo',
|
|
'100644',
|
|
objectId,
|
|
path,
|
|
]),
|
|
'brain-git-reconcile-checkout-index',
|
|
);
|
|
}
|
|
};
|
|
const verifyCommitPaths = (commit: string): void => {
|
|
const changed = runGit(run, input.identity, [
|
|
'-C',
|
|
input.root,
|
|
'diff-tree',
|
|
'--root',
|
|
'--no-commit-id',
|
|
'--name-only',
|
|
'-r',
|
|
'-z',
|
|
commit,
|
|
]);
|
|
requireSuccess(changed, 'brain-git-read-commit-paths');
|
|
const names = changed.stdout.split('\0').filter(Boolean);
|
|
const approved = new Set(paths);
|
|
if (names.length === 0 || names.some((name: string): boolean => !approved.has(name))) {
|
|
throw new Error('brain-git-commit-paths-unapproved');
|
|
}
|
|
};
|
|
|
|
const base = readHead();
|
|
const indexRoot = mkdtempSync(join(tmpdir(), 'mosaic-brain-index-'));
|
|
const isolatedEnv = { GIT_INDEX_FILE: join(indexRoot, 'index') };
|
|
let commit = base;
|
|
let createdCommit = false;
|
|
try {
|
|
requireSuccess(
|
|
runGitWithEnv(run, input.identity, ['-C', input.root, 'read-tree', base], isolatedEnv),
|
|
'brain-git-isolated-index-init',
|
|
);
|
|
for (const entry of entries) {
|
|
const object = runGitWithEnv(
|
|
run,
|
|
input.identity,
|
|
['-C', input.root, 'hash-object', '-w', '--stdin'],
|
|
isolatedEnv,
|
|
entry.content,
|
|
);
|
|
requireSuccess(object, 'brain-git-write-approved-object');
|
|
const objectId = object.stdout.trim();
|
|
if (!GIT_OBJECT.test(objectId)) throw new Error('brain-git-object-shape-invalid');
|
|
expectedObjects.set(entry.path, objectId);
|
|
requireSuccess(
|
|
runGitWithEnv(
|
|
run,
|
|
input.identity,
|
|
[
|
|
'-C',
|
|
input.root,
|
|
'update-index',
|
|
'--add',
|
|
'--cacheinfo',
|
|
'100644',
|
|
objectId,
|
|
entry.path,
|
|
],
|
|
isolatedEnv,
|
|
),
|
|
'brain-git-stage-approved-object',
|
|
);
|
|
}
|
|
const difference = runGitWithEnv(
|
|
run,
|
|
input.identity,
|
|
['-C', input.root, 'diff', '--cached', '--quiet', '--exit-code', base, '--', ...paths],
|
|
isolatedEnv,
|
|
);
|
|
if (difference.status === 1) {
|
|
requireSuccess(
|
|
runGitWithEnv(
|
|
run,
|
|
input.identity,
|
|
[
|
|
'-C',
|
|
input.root,
|
|
'-c',
|
|
`user.name=${input.identity}`,
|
|
'-c',
|
|
`user.email=${input.identity}@fleet.mosaicstack.dev`,
|
|
'commit',
|
|
'-m',
|
|
input.message,
|
|
],
|
|
isolatedEnv,
|
|
),
|
|
'brain-git-commit',
|
|
);
|
|
commit = readHead();
|
|
verifyCommitPaths(commit);
|
|
verifyCommitObjects(commit);
|
|
verifyCommitIdentity(commit);
|
|
reconcileRealIndex();
|
|
createdCommit = true;
|
|
} else if (difference.status !== 0) {
|
|
throw new Error('brain-git-isolated-diff-failed');
|
|
}
|
|
} finally {
|
|
rmSync(indexRoot, { recursive: true, force: true });
|
|
}
|
|
|
|
let pushed = !createdCommit;
|
|
for (let attempt = 0; createdCommit && attempt < 3; attempt += 1) {
|
|
const push = runGit(run, input.identity, ['-C', input.root, 'push', 'origin', 'HEAD:main']);
|
|
if (push.status === 0) {
|
|
pushed = true;
|
|
break;
|
|
}
|
|
const concurrentUpdate = /non-fast-forward|fetch first|\[rejected\]/i.test(push.stderr);
|
|
if (!concurrentUpdate || attempt === 2) throw new Error('brain-git-push-failed');
|
|
requireSuccess(
|
|
runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']),
|
|
'brain-git-fetch-concurrent',
|
|
);
|
|
requireSuccess(
|
|
runGit(run, input.identity, [
|
|
'-C',
|
|
input.root,
|
|
'-c',
|
|
`user.name=${input.identity}`,
|
|
'-c',
|
|
`user.email=${input.identity}@fleet.mosaicstack.dev`,
|
|
'rebase',
|
|
'origin/main',
|
|
]),
|
|
'brain-git-rebase-concurrent',
|
|
);
|
|
commit = readHead();
|
|
verifyCommitPaths(commit);
|
|
verifyCommitObjects(commit);
|
|
verifyCommitIdentity(commit);
|
|
}
|
|
if (!pushed) throw new Error('brain-git-push-failed');
|
|
requireSuccess(
|
|
runGit(run, input.identity, ['-C', input.root, 'fetch', 'origin', 'main']),
|
|
'brain-git-fetch-readback',
|
|
);
|
|
const reachableResult = runGit(run, input.identity, [
|
|
'-C',
|
|
input.root,
|
|
'merge-base',
|
|
'--is-ancestor',
|
|
commit,
|
|
'origin/main',
|
|
]);
|
|
if (reachableResult.status !== 0 && reachableResult.status !== 1) {
|
|
throw new Error('brain-git-reachability-check-failed');
|
|
}
|
|
const remoteResult = runGit(run, input.identity, ['-C', input.root, 'rev-parse', 'origin/main']);
|
|
requireSuccess(remoteResult, 'brain-git-read-remote-head');
|
|
const remoteHead = remoteResult.stdout.trim();
|
|
if (!COMMIT.test(remoteHead)) throw new Error('brain-git-remote-head-shape-invalid');
|
|
return { commit, remoteHead, reachable: reachableResult.status === 0 };
|
|
}
|