feat(mosaic): provision per-estate durable brain
This commit is contained in:
+3
-3
@@ -104,18 +104,18 @@ In scope now: estate derivation, secret exclusion, non-destructive migration, do
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-MB-01`: Contract tests observe RED before implementation and then distinguish `ok/0`, `refused/10`, `error/20`, and `indeterminate/30`, preserving stable reason codes including `identity-not-found`, `credential-rejected`, and `provider-unavailable`.
|
||||
1. `AC-MB-01`: Contract tests observe RED before implementation and then distinguish `ok/0`, `refused/10`, `error/20`, and `indeterminate/30`, preserving v1.5 diagnoses including refused `provider-identity-mismatch`/`credential-rejected` and indeterminate `identity-not-measured`/`provider-unavailable`. A scope-forbidden `/user` result with confirmed in-scope repository capability is never represented as a dead credential. `identity-not-found` remains reserved for a future visibility-authorized inventory operation and is not an expected `validate` result.
|
||||
2. `AC-MB-02`: Estate resolution uses the configured target git host and one registry; unknown, mismatched, and host-machine-derived inputs fail closed.
|
||||
3. `AC-MB-03`: A clean fixture contains the required layout and exact secret exclusions, and seeded secret-shaped files remain ignored without their values appearing in output.
|
||||
4. `AC-MB-04`: Migration moves lane-durable and seat-state content into collision-safe archive/ledger paths, preserves source on any incomplete move, never overwrites an existing finding, never deletes by age/size, and reports unresolved items explicitly.
|
||||
5. `AC-MB-05`: Doctor detects all four R8 defect classes; `--fix` repairs eligible classes through the approved P7/broker seam and leaves unresolved credential-dependent states visible.
|
||||
6. `AC-MB-06`: Git-axis and API-axis refusal must both be authoritative `refused` outcomes with matching stable reason codes; any disagreement yields `indeterminate`.
|
||||
7. `AC-MB-07`: Independent code review and security review pass at the exact head, and HOMELAB Woodpecker instance `mosaic` is terminal green before integration.
|
||||
8. `AC-MB-08`: Integration into `next` is reported only as **believed-fixed, pending validation AND pending promotion to `main`**; issue #1051 remains open for #1037 promotion and W-jarvis validation.
|
||||
8. `AC-MB-08`: After reviewed merge to `main`, report only **believed-fixed, pending jarvis validation**; issue #1051 remains open until W-jarvis validates the installed result.
|
||||
|
||||
### Constraints and risks
|
||||
|
||||
- MC-CRED-01 contract v1.3 is the caller boundary; no independent credential/token lookup is permitted.
|
||||
- MC-CRED-01 contract v1.5 is the caller boundary; no independent credential/token lookup is permitted. Identity is established from governed mint-time binding and provider evidence when measurable, never a credential filename. Runtime validation does not widen a least-privilege token merely to make `/user` observable.
|
||||
- C1 owns installer phase sequencing. This slice consumes P5/P7 ordering without renumbering or duplicating the phase machine.
|
||||
- Lane content is findings, so last-writer-wins is data loss. Append-only names and explicit collision handling are mandatory.
|
||||
- A created-but-empty brain beside unbacked local doctrine fails the objective; migration is a primary acceptance gate.
|
||||
|
||||
@@ -10,9 +10,9 @@ Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusi
|
||||
|
||||
- Provider issue: HOMELAB `git.mosaicstack.dev`, `GET /api/v1/repos/mosaicstack/stack/issues/1051`, `application/json;charset=utf-8`.
|
||||
- Issue requirements: R1–R8 read directly on 2026-08-05.
|
||||
- MC-CRED caller contract: v1.3, SHA-256 `8cfa4853d2b0b0e8cc9e792fa8411310e16d7704c06e0af9d9a57155131d8086` at intake.
|
||||
- MC-CRED caller contract: v1.5, SHA-256 `4cecba3386b37431d4a075205c6dfe43555c7673922fed61b84f43cac1a6ae92` at the 2026-08-05 re-derivation. Earlier moving bindings were v1.5 `710d22d61a93a4b9c70fc55506a023a675a110417fa7a6e72dc051c0d9fe8237`, v1.4 `27f20158561ae8292f3bfc926b5e97f398de93db6a1cf65fcc215d08811d39af`/`d12ad4595b7aef078e392988a07ab5cb00244440775c9c733dc825746d7ac67b`, and v1.3 `8cfa4853d2b0b0e8cc9e792fa8411310e16d7704c06e0af9d9a57155131d8086`.
|
||||
- Fleet doctrine: SHA-256 `026b43322e0551ef15b646a9f30d3a6aef58c662a810b732be2a03b1ecf7d36e` at intake.
|
||||
- Base: HOMELAB provider `next` = `4df478cdd150fdf8d52ea109f02ade5d85017acd`; `main` = `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`; provider branch objects matched fetched refs and `main` is reachable from `next`.
|
||||
- Intake base was HOMELAB provider `next` = `4df478cdd150fdf8d52ea109f02ade5d85017acd`; `main` = `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`. On 2026-08-05 `mos-claude` ruled that L0 trunk-based gate 15 requires all three lanes to retarget to `main`; `next` remains a non-merging integration branch. Never weaken or patch `pr-merge.sh`.
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -33,6 +33,15 @@ Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusi
|
||||
- No phase renumbering; C1 owns the phase machine and provides the P5→P7 seam.
|
||||
- No age/size reaping or deletion.
|
||||
|
||||
## Owner authority ruling and resolver seam
|
||||
|
||||
- Binding addendum: `/home/hermes/agent-work/tl-mosaic/CHARTER-MB-BRAIN-01-ADDENDUM.md`; re-read after compaction.
|
||||
- HOMELAB durable lane-archive owner and user-namespace brain owner are the human provider account selected by local estate policy (operator ruling: `jason.woltje`) with a required GLPI queue as the standing remediation process. The brain target is therefore `<policy-owner>/mosaic-brain` on the estate host, not `<installer-source-org>/mosaic-brain`. Framework source remains operator-agnostic: the actual login and queue are local policy, not hardcoded open-source context.
|
||||
- Provider lookup is anonymous because the ruled owner is public. It requires exact allowlisted login plus a same-invocation public known-good control, private 404 control, and generated absent 404 control. It sends no Authorization header and never widens token scope.
|
||||
- Provider `active` is deliberately ignored: non-admin reads return false for demonstrably active accounts. Resolvability + exact login + public visibility are the gate.
|
||||
- Private and absent principals both return anonymous 404. The fail-closed reason is `owner-not-resolvable`, never owner-not-found.
|
||||
- Caller `owner` strings and `validated=true` are ignored. Migration consumes only an injected source-of-truth resolver result. Owner grammar is NFKC-stable, ASCII allowlisted, exact-policy matched, and mission-seat class is excluded.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Pre-register acceptance tests and observe each requirement RED for its own missing behavior.
|
||||
@@ -42,7 +51,7 @@ Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusi
|
||||
5. Integrate the helper into C1's P7 seam and `mosaic doctor` after C1 lands/rebase.
|
||||
6. Run focused, package, installer, lint, typecheck, format, and situational security tests.
|
||||
7. Run independent code and security reviews in parallel; remediate and re-review.
|
||||
8. Push after HOMELAB queue guard, open PR to `next`, and wait for merge order C1 → MC-CRED → MB-BRAIN.
|
||||
8. Push after HOMELAB queue guard, open the reviewed PR to `main`, and preserve merge order C1 → MC-CRED → MB-BRAIN. Do not modify the merge guard; `next` is non-merging integration only.
|
||||
9. Re-take CI measurement at the rebased exact head; do not rework code solely because base evidence moved.
|
||||
|
||||
## Acceptance interpretation registered before results
|
||||
@@ -51,7 +60,7 @@ Codify estate-derived, repository-backed `~/.mosaic` support with secret exclusi
|
||||
- `refused/10`: complete authoritative denial only.
|
||||
- `error/20`: local contract/control failure; never reinterpret as denial.
|
||||
- `indeterminate/30`: incomplete/disagreeing evidence; fail closed, never resolve permissively.
|
||||
- Both Git and API axes must return authoritative `refused` with the same stable reason code for R5. Any axis disagreement is `indeterminate`.
|
||||
- Both Git and API axes must return authoritative `refused` with the same stable reason code for R5. Any axis disagreement is `indeterminate`. A provider `/user` login mismatch is first-class `provider-identity-mismatch`; credential filenames never establish principal identity.
|
||||
- Migration success requires the durable object to contain the moved item and no overwrite; incomplete moves retain the source and are reported.
|
||||
- Secret exclusion is tested through both exact ignore rules and seeded secret-shaped controls; output is scanned without printing secret values.
|
||||
|
||||
@@ -61,7 +70,7 @@ No explicit token ceiling was supplied. Working cap: 55K tokens for implementati
|
||||
|
||||
## Risks
|
||||
|
||||
- C1 and MC-CRED branches have not merged into `next`; integration edits must wait for their exact interfaces or be confined to stable contract seams.
|
||||
- C1 and MC-CRED branches have not merged into `main`; integration edits must wait for their exact interfaces or be confined to stable contract seams.
|
||||
- A broker runtime test before MC-CRED lands would either fail for an irrelevant reason or pressure a hand-rolled workaround; contract fixtures are allowed, live capability claims are not.
|
||||
- Migration can lose data through overwrite, cross-device move failure, or partial copy. Implementation must stage, verify resulting bytes, and retain/report source on incomplete transfer.
|
||||
- `~/.mosaic` is a git repo, while current working state may live under multiple local roots; detection must be explicit and cannot treat age/size as ownership.
|
||||
@@ -70,15 +79,22 @@ No explicit token ceiling was supplied. Working cap: 55K tokens for implementati
|
||||
|
||||
- [x] Charter receipt accepted by `tl-mosaic`.
|
||||
- [x] Issue #1051 R1–R8 read directly from provider.
|
||||
- [x] Contract re-derived at v1.3.
|
||||
- [x] Contract re-derived through v1.3, moving v1.4, and v1.5 before R5 integration. v1.5 separates in-scope repository capability from `/user` identity measurement: 401 is `credential-rejected`/refused, 403/404 may become `identity-not-measured` only after in-scope capability succeeds, and 200 login mismatch is refused. `identity-not-found` is not reachable from `validate`.
|
||||
- [x] C1 P5→P7 seam receipt read; no brain implementation is in C1.
|
||||
- [ ] RED acceptance set committed.
|
||||
- [ ] Implementation green.
|
||||
- [x] RED acceptance set committed at `cf11c6c86abae073d8b02b4014cd5447ba67f12a`; author and committer read back as `be-coder-07` and branch reachability was independently verified by `tl-mosaic`.
|
||||
- [x] Moving-contract REDs observed independently for v1.4 mismatch, R8 prerequisite ordering, owner resolver seam/allowlist, tracked skeleton/no-follow behavior, runtime observation/publication, and provider owner resolution.
|
||||
- [x] Focused implementation includes secure migration, v1.5 write-differential/subject binding, production Git+API refusal parity, provider-backed durable owner resolution that ignores non-admin `active`, required GLPI standing-process policy, P7 provision orchestration, an internal installer command, and installed `mosaic doctor` wiring. Latest focused result: 76/76 (store 38, runtime 12, owner resolver 14, provision 4, provision command 3, installed doctor 5).
|
||||
- [x] MC-CRED added the required canonical reverse registry seam `ParsedCredentialEstateRegistry.resolveByHost()` at dependency head `6ca8758f`; current local copies are temporary until dependency integration and the 32-line permissive shim has been removed.
|
||||
- [x] Identity gotcha measured: inline `MOSAIC_GIT_IDENTITY=be-coder-07` controls credential resolution but does not override `user.name`/`user.email` inherited from the linked worktree common-dir config (`coder-mos1`). The first local P7 RED commit was immediately amended before push with command-scoped `GIT_AUTHOR_*` + `GIT_COMMITTER_*`; resulting author and committer both read back as `be-coder-07`. Every subsequent authoring command must carry both identity sets and be verified.
|
||||
- [x] R6 migration now reports secret-shaped files without copying them; symlinked `.gitignore`, layout directories, and nested migration destinations fail closed.
|
||||
- [x] Multi-host push-on-write retries non-fast-forward races via fetch/rebase rather than LWW, and unknown publication reachability retains both sources and local copies.
|
||||
- [ ] Installer shell P7 invocation after C1 + MC-CRED integration; production command is registered but the C1 shell has not yet called it.
|
||||
- [ ] Implementation green on merged dependency base.
|
||||
- [ ] Independent code review.
|
||||
- [ ] Independent security review.
|
||||
- [ ] HOMELAB CI terminal green at exact head.
|
||||
- [ ] Integrated to `next` after C1 and MC-CRED.
|
||||
- [ ] Reviewed PR retargeted to `main` after C1 and MC-CRED; `next` remains non-merging integration only.
|
||||
|
||||
## Completion language
|
||||
|
||||
Only: **believed-fixed, pending validation AND pending promotion to `main`**. Issue #1051 remains open; #1037 is the promotion vehicle and W-jarvis is the external validator.
|
||||
After reviewed merge to `main`, only: **believed-fixed, pending jarvis validation**. Issue #1051 remains open until W-jarvis validates the installed result.
|
||||
|
||||
@@ -23,6 +23,7 @@ import { registerSkillCommand } from './commands/skill.js';
|
||||
import { registerLaunchCommands } from './commands/launch.js';
|
||||
import { registerLeaseCapabilityProbe } from './commands/lease-activation-probe.js';
|
||||
import { registerInstallOrderingGuardCommand } from './commands/install-ordering-guard.js';
|
||||
import { registerBrainProvisionCommand } from './commands/brain-provision-command.js';
|
||||
import { registerAuthCommand } from './commands/auth.js';
|
||||
import { registerFederationCommand } from './commands/federation.js';
|
||||
import { registerGatewayCommand } from './commands/gateway.js';
|
||||
@@ -85,6 +86,10 @@ registerLeaseCapabilityProbe(program);
|
||||
|
||||
registerInstallOrderingGuardCommand(program);
|
||||
|
||||
// ─── durable brain P7 provisioner (hidden; #1051) ───────────────────────
|
||||
|
||||
registerBrainProvisionCommand(program);
|
||||
|
||||
// ─── login ──────────────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface CommandRequest {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly status: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
interface InstalledDoctorResult {
|
||||
readonly status: 'ok' | 'warn' | 'error';
|
||||
readonly findings: readonly {
|
||||
readonly code: string;
|
||||
readonly reasonCode: string | null;
|
||||
}[];
|
||||
readonly lines: readonly string[];
|
||||
}
|
||||
|
||||
interface BrainDoctorModule {
|
||||
runInstalledBrainDoctorCheck(
|
||||
options: {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity?: string;
|
||||
readonly fix: boolean;
|
||||
},
|
||||
run: (request: CommandRequest) => CommandResult,
|
||||
): InstalledDoctorResult;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-doctor-check.js';
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadDoctor(requirement: string): Promise<BrainDoctorModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as BrainDoctorModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: installed brain doctor check is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-doctor-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function installConfig(root: string): { readonly home: string; readonly mosaicHome: string } {
|
||||
const home = join(root, 'home');
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
mkdirSync(join(mosaicHome, 'cred'), { recursive: true });
|
||||
mkdirSync(join(mosaicHome, 'brain'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(mosaicHome, 'cred', 'estates.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
writeFileSync(
|
||||
join(mosaicHome, 'brain', 'owners.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
estate: 'homelab',
|
||||
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||
controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
writeFileSync(
|
||||
join(mosaicHome, '.install-manifest.json'),
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
status: 'committed',
|
||||
sourceRepo: 'https://git.example.invalid/example/stack.git',
|
||||
}),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
return { home, mosaicHome };
|
||||
}
|
||||
|
||||
function validateResult(outcome: 'ok' | 'refused' | 'indeterminate', reasonCode: string): string {
|
||||
const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30;
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation: 'validate',
|
||||
outcome,
|
||||
exitCode,
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity: 'seat-a',
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'durable-owner/mosaic-brain',
|
||||
},
|
||||
mutation: 'none',
|
||||
reason: { code: reasonCode, message: 'non-secret' },
|
||||
evidence: {
|
||||
providerIdentity:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
login: 'seat-a',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
repositoryPermission:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
requested: 'write',
|
||||
effective: 'write',
|
||||
endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
writeDifferential:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
state: 'can-write',
|
||||
credentialBinding: 'same-resolution',
|
||||
transportPrincipal: 'seat-a',
|
||||
authenticatedReceivePack: 'advertised',
|
||||
readOnlyControl: {
|
||||
identity: 'read-control',
|
||||
providerPermission: 'read',
|
||||
receivePack: 'refused',
|
||||
},
|
||||
unauthenticatedReceivePack: 'refused',
|
||||
artifactCreated: false,
|
||||
proves: 'non-secret evidence',
|
||||
doesNotProve: 'branch update acceptance',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
audit: { journalId: 'opaque', state: 'sealed' },
|
||||
});
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('installed mosaic doctor brain checks', (): void => {
|
||||
it('derives the target from the committed install manifest and surfaces a missing clone plus refusal', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-08 installed doctor missing clone');
|
||||
const config = installConfig(tempRoot());
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ ...config, identity: 'seat-a', fix: false },
|
||||
(request): CommandResult => {
|
||||
requests.push(request);
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('warn');
|
||||
expect(result.findings.map((finding) => finding.code)).toEqual(
|
||||
expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']),
|
||||
);
|
||||
expect(result.lines.join('\n')).toMatch(/brain-clone-missing/);
|
||||
expect(result.lines.join('\n')).toMatch(/no-token-for-identity/);
|
||||
expect(requests[0]?.args).toContain('durable-owner/mosaic-brain');
|
||||
});
|
||||
|
||||
it('fails closed without an explicit identity and performs no command', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-08 explicit identity');
|
||||
const config = installConfig(tempRoot());
|
||||
let calls = 0;
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ ...config, fix: false },
|
||||
(): CommandResult => {
|
||||
calls += 1;
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'error',
|
||||
findings: [{ code: 'brain-identity-required', reasonCode: 'identity-required' }],
|
||||
});
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it('treats identity-not-measured as an error, not no-write refusal and not a repairable grant case', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-08 identity measurement axis');
|
||||
const config = installConfig(tempRoot());
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ ...config, identity: 'seat-a', fix: true },
|
||||
(request): CommandResult => {
|
||||
requests.push(request);
|
||||
return {
|
||||
status: 30,
|
||||
stdout: validateResult('indeterminate', 'identity-not-measured'),
|
||||
stderr: 'identity not measured',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: 'brain-write-access-indeterminate',
|
||||
reasonCode: 'identity-not-measured',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is wired into the top-level mosaic doctor path before the shell audit runs', (): void => {
|
||||
const launch = readFileSync(join(process.cwd(), 'src', 'commands', 'launch.ts'), 'utf8');
|
||||
|
||||
expect(launch).toContain('runInstalledBrainDoctorCheck');
|
||||
expect(launch).toContain('defaultInstalledBrainDoctorOptions');
|
||||
expect(launch).toContain('systemCommandRunner');
|
||||
expect(launch).toMatch(/brainCheckFailed[\s\S]*runDoctorScriptAndExit/);
|
||||
});
|
||||
|
||||
it('reports a missing or unsafe registry/manifest as configuration error rather than defaulting estate', async (): Promise<void> => {
|
||||
const doctor = await loadDoctor('MB-REQ-02 missing mapping fail-closed');
|
||||
const root = tempRoot();
|
||||
const home = join(root, 'home');
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
mkdirSync(mosaicHome, { recursive: true });
|
||||
|
||||
const result = doctor.runInstalledBrainDoctorCheck(
|
||||
{ home, mosaicHome, identity: 'seat-a', fix: false },
|
||||
(): CommandResult => ({ status: 0, stdout: '', stderr: '' }),
|
||||
);
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.findings[0]?.code).toMatch(/brain-(estate-registry|install-manifest)-/);
|
||||
expect(result.lines.join('\n')).not.toMatch(/homelab|usc/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import { resolveBrainOwnerPolicy } from './brain-owner-resolver.js';
|
||||
import { deriveBrainTarget } from './brain-store.js';
|
||||
import {
|
||||
collectBrainDoctorReport,
|
||||
repairBrainDoctor,
|
||||
type CommandRunner,
|
||||
type DoctorRuntimeReport,
|
||||
} from './brain-store-runtime.js';
|
||||
|
||||
const MAX_CONFIG_BYTES = 256 * 1024;
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const manifestSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
status: z.literal('committed'),
|
||||
sourceRepo: z.string().min(1),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export interface InstalledDoctorFinding {
|
||||
readonly code: string;
|
||||
readonly reasonCode: string | null;
|
||||
}
|
||||
|
||||
export interface InstalledDoctorResult {
|
||||
readonly status: 'ok' | 'warn' | 'error';
|
||||
readonly findings: readonly InstalledDoctorFinding[];
|
||||
readonly lines: readonly string[];
|
||||
}
|
||||
|
||||
function configurationError(code: string, reasonCode = code): InstalledDoctorResult {
|
||||
return {
|
||||
status: 'error',
|
||||
findings: [{ code, reasonCode }],
|
||||
lines: [`[mosaic-doctor] [ERROR] ${code}`],
|
||||
};
|
||||
}
|
||||
|
||||
function readUtf8(path: string, root: string): string {
|
||||
const snapshot = readRegularFileSecure(path, { root, maxBytes: MAX_CONFIG_BYTES });
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(snapshot.content);
|
||||
} catch {
|
||||
throw new Error('config-not-utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function renderReport(report: DoctorRuntimeReport): InstalledDoctorResult {
|
||||
const findings = report.findings.map(
|
||||
(finding): InstalledDoctorFinding => ({
|
||||
code: finding.code,
|
||||
reasonCode: finding.reasonCode,
|
||||
}),
|
||||
);
|
||||
const hard = findings.some(
|
||||
(finding): boolean =>
|
||||
finding.code.endsWith('-error') ||
|
||||
finding.code.endsWith('-indeterminate') ||
|
||||
finding.code === 'brain-not-git-repository',
|
||||
);
|
||||
const status: InstalledDoctorResult['status'] =
|
||||
findings.length === 0 ? 'ok' : hard ? 'error' : 'warn';
|
||||
const severity = status === 'error' ? 'ERROR' : status === 'warn' ? 'WARN' : 'OK';
|
||||
const lines =
|
||||
findings.length === 0
|
||||
? ['[mosaic-doctor] [OK] mosaic-brain ready']
|
||||
: findings.map(
|
||||
(finding): string =>
|
||||
`[mosaic-doctor] [${severity}] ${finding.code}${
|
||||
finding.reasonCode === null ? '' : ` reason=${finding.reasonCode}`
|
||||
}`,
|
||||
);
|
||||
return { status, findings, lines };
|
||||
}
|
||||
|
||||
export function runInstalledBrainDoctorCheck(
|
||||
options: {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity?: string;
|
||||
readonly fix: boolean;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): InstalledDoctorResult {
|
||||
if (options.identity === undefined || !IDENTITY.test(options.identity)) {
|
||||
return configurationError('brain-identity-required', 'identity-required');
|
||||
}
|
||||
|
||||
const registryPath = join(options.mosaicHome, 'cred', 'estates.json');
|
||||
const manifestPath = join(options.mosaicHome, '.install-manifest.json');
|
||||
const ownerPolicyPath = join(options.mosaicHome, 'brain', 'owners.json');
|
||||
let registrySource: string;
|
||||
try {
|
||||
registrySource = readUtf8(registryPath, options.mosaicHome);
|
||||
} catch {
|
||||
return configurationError('brain-estate-registry-unavailable');
|
||||
}
|
||||
let manifestSource: string;
|
||||
try {
|
||||
manifestSource = readUtf8(manifestPath, options.mosaicHome);
|
||||
} catch {
|
||||
return configurationError('brain-install-manifest-unavailable');
|
||||
}
|
||||
let manifestRaw: unknown;
|
||||
try {
|
||||
manifestRaw = JSON.parse(manifestSource);
|
||||
} catch {
|
||||
return configurationError('brain-install-manifest-invalid');
|
||||
}
|
||||
const manifest = manifestSchema.safeParse(manifestRaw);
|
||||
if (!manifest.success) return configurationError('brain-install-manifest-invalid');
|
||||
let ownerPolicySource: string;
|
||||
try {
|
||||
ownerPolicySource = readUtf8(ownerPolicyPath, options.mosaicHome);
|
||||
} catch {
|
||||
return configurationError('brain-owner-policy-unavailable');
|
||||
}
|
||||
let preliminaryTarget: ReturnType<typeof deriveBrainTarget>;
|
||||
try {
|
||||
preliminaryTarget = deriveBrainTarget(registrySource, manifest.data.sourceRepo, 'policy-probe');
|
||||
} catch {
|
||||
return configurationError('brain-estate-registry-invalid');
|
||||
}
|
||||
const ownerPolicy = resolveBrainOwnerPolicy(ownerPolicySource, preliminaryTarget.estate);
|
||||
if (ownerPolicy === undefined) return configurationError('brain-owner-policy-invalid');
|
||||
|
||||
const input = {
|
||||
registrySource,
|
||||
targetGitUrl: manifest.data.sourceRepo,
|
||||
brainNamespace: ownerPolicy.brainNamespace,
|
||||
identity: options.identity,
|
||||
root: join(options.home, '.mosaic'),
|
||||
};
|
||||
try {
|
||||
return renderReport(
|
||||
options.fix ? repairBrainDoctor(input, run) : collectBrainDoctorReport(input, run),
|
||||
);
|
||||
} catch {
|
||||
return configurationError('brain-estate-registry-invalid');
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultInstalledBrainDoctorOptions(fix: boolean): {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity?: string;
|
||||
readonly fix: boolean;
|
||||
} {
|
||||
const home = homedir();
|
||||
const identity = process.env['MOSAIC_GIT_IDENTITY'];
|
||||
return {
|
||||
mosaicHome: process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic'),
|
||||
home,
|
||||
...(identity === undefined ? {} : { identity }),
|
||||
fix,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Red-first owner-authority resolver contract for #1051.
|
||||
*
|
||||
* Fixtures are operator-agnostic. The HOMELAB owner name belongs in the local
|
||||
* estate policy, never in framework source. Anonymous lookup is intentional:
|
||||
* the ruled owner class is PUBLIC and least-privilege seats may lack read:user.
|
||||
*/
|
||||
|
||||
interface MigrationOwnerResolution {
|
||||
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||||
readonly reasonCode: string;
|
||||
readonly principal: {
|
||||
readonly name: string;
|
||||
readonly kind: 'durable-human';
|
||||
} | null;
|
||||
readonly authority: {
|
||||
readonly system: 'gitea';
|
||||
readonly endpoint: string;
|
||||
readonly contentType: 'application/json';
|
||||
} | null;
|
||||
}
|
||||
|
||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface OwnerResolverModule {
|
||||
resolveProviderDurableOwner(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly host: string;
|
||||
readonly requestedOwner: string;
|
||||
},
|
||||
dependencies: {
|
||||
readonly fetch: FetchLike;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<MigrationOwnerResolution>;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-owner-resolver.js';
|
||||
|
||||
async function loadResolver(requirement: string): Promise<OwnerResolverModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as OwnerResolverModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: brain owner resolver is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function estateRegistry(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function ownerPolicy(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
estate: 'homelab',
|
||||
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||
controls: {
|
||||
publicIdentity: 'public-control',
|
||||
privateIdentity: 'private-control',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
function publicUser(login: string, active = false): Response {
|
||||
return jsonResponse(200, {
|
||||
id: 42,
|
||||
login,
|
||||
visibility: 'public',
|
||||
active,
|
||||
});
|
||||
}
|
||||
|
||||
function identityFromUrl(input: string | URL | Request): string {
|
||||
const value = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
return decodeURIComponent(new URL(value).pathname.split('/').at(-1) ?? '');
|
||||
}
|
||||
|
||||
function controlledFetch(
|
||||
overrides: Readonly<Record<string, Response>> = {},
|
||||
calls: Array<{ identity: string; authorization: string | null }> = [],
|
||||
): FetchLike {
|
||||
return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const identity = identityFromUrl(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
calls.push({ identity, authorization: headers.get('authorization') });
|
||||
const override = overrides[identity];
|
||||
if (override !== undefined) return override.clone();
|
||||
if (identity === 'public-control') return publicUser('public-control');
|
||||
if (identity === 'private-control' || identity === 'generated-absent-control') {
|
||||
return jsonResponse(404, { message: 'not found' });
|
||||
}
|
||||
if (identity === 'durable-owner') return publicUser('durable-owner', false);
|
||||
return jsonResponse(404, { message: 'not found' });
|
||||
};
|
||||
}
|
||||
|
||||
describe('provider-backed durable owner resolver', (): void => {
|
||||
it('resolves an allowlisted PUBLIC owner by exact login with public/private/absent controls and ignores active=false', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 provider owner resolution');
|
||||
const calls: Array<{ identity: string; authorization: string | null }> = [];
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch({}, calls),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
verdict: 'resolved',
|
||||
reasonCode: 'owner-verified',
|
||||
principal: { name: 'user:durable-owner', kind: 'durable-human' },
|
||||
authority: {
|
||||
system: 'gitea',
|
||||
endpoint: 'GET /api/v1/users/durable-owner',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
});
|
||||
expect(calls.map((call) => call.identity)).toEqual([
|
||||
'public-control',
|
||||
'private-control',
|
||||
'generated-absent-control',
|
||||
'durable-owner',
|
||||
]);
|
||||
expect(calls.every((call) => call.authorization === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('requires the GLPI standing remediation queue in the local estate policy', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 standing process policy');
|
||||
const raw = JSON.parse(ownerPolicy()) as { estates: Array<Record<string, unknown>> };
|
||||
delete raw.estates[0]?.['standingProcess'];
|
||||
let fetchCalls = 0;
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: JSON.stringify(raw),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: async (): Promise<Response> => {
|
||||
fetchCalls += 1;
|
||||
return publicUser('durable-owner');
|
||||
},
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-policy-invalid' });
|
||||
expect(fetchCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a provider-valid but unlisted principal before provider lookup', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 provider-valid unlisted owner refusal');
|
||||
const calls: Array<{ identity: string; authorization: string | null }> = [];
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:other-public-user',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch({ 'other-public-user': publicUser('other-public-user') }, calls),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'refused', reasonCode: 'owner-not-allowlisted' });
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['user:durable–owner', 'owner-name-invalid'],
|
||||
[' user:durable-owner ', 'owner-name-invalid'],
|
||||
['user:durable.owner', 'owner-not-allowlisted'],
|
||||
['user:durable owner', 'owner-name-invalid'],
|
||||
['user:durable-owner', 'owner-name-invalid'],
|
||||
['user:be-coder-07@mission-seat', 'owner-name-invalid'],
|
||||
] as const)(
|
||||
'rejects non-canonical, unlisted, or transient-seat presentation %s before lookup',
|
||||
async (name, reasonCode): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 owner allowlist grammar');
|
||||
let fetchCalls = 0;
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: name,
|
||||
},
|
||||
{
|
||||
fetch: async (): Promise<Response> => {
|
||||
fetchCalls += 1;
|
||||
return publicUser('durable-owner');
|
||||
},
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ verdict: 'refused', reasonCode });
|
||||
expect(fetchCalls).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('fails closed as not-resolvable rather than claiming a private-or-absent owner does not exist', async (): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 private/absent ambiguity');
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch({ 'durable-owner': jsonResponse(404, { message: 'hidden' }) }),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
verdict: 'not-measured',
|
||||
reasonCode: 'owner-not-resolvable',
|
||||
principal: null,
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['public control hidden', { 'public-control': jsonResponse(404, {}) }],
|
||||
['public control login mismatch', { 'public-control': publicUser('other') }],
|
||||
['private control unexpectedly public', { 'private-control': publicUser('private-control') }],
|
||||
[
|
||||
'generated absent control unexpectedly resolves',
|
||||
{ 'generated-absent-control': publicUser('generated-absent-control') },
|
||||
],
|
||||
] as const)(
|
||||
'makes the whole result not-measured when %s',
|
||||
async (_caseName, overrides): Promise<void> => {
|
||||
const resolver = await loadResolver('MB-REQ-09 owner resolver controls');
|
||||
|
||||
const result = await resolver.resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
host: 'git.example.invalid',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
},
|
||||
{
|
||||
fetch: controlledFetch(overrides),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
verdict: 'not-measured',
|
||||
reasonCode: 'owner-control-invalid',
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { z } from 'zod';
|
||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||
import type { MigrationOwnerResolution } from './brain-store.js';
|
||||
|
||||
const MAX_BODY_BYTES = 256 * 1024;
|
||||
const LOGIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/;
|
||||
const REQUESTED_OWNER = /^user:(.+)$/;
|
||||
|
||||
const ownerPolicySchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
estates: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
estate: z.string().min(1),
|
||||
laneArchiveOwners: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('provider-user'),
|
||||
login: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(1),
|
||||
standingProcess: z
|
||||
.object({
|
||||
kind: z.literal('glpi-queue'),
|
||||
queue: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
||||
})
|
||||
.strict(),
|
||||
controls: z
|
||||
.object({
|
||||
publicIdentity: z.string().min(1),
|
||||
privateIdentity: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const providerUserSchema = z
|
||||
.object({
|
||||
id: z.number().int(),
|
||||
login: z.string().min(1),
|
||||
visibility: z.literal('public'),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type OwnerFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
function unresolved(reasonCode: string): MigrationOwnerResolution {
|
||||
return {
|
||||
verdict: 'not-measured',
|
||||
reasonCode,
|
||||
principal: null,
|
||||
authority: null,
|
||||
};
|
||||
}
|
||||
|
||||
function refused(reasonCode: string): MigrationOwnerResolution {
|
||||
return {
|
||||
verdict: 'refused',
|
||||
reasonCode,
|
||||
principal: null,
|
||||
authority: null,
|
||||
};
|
||||
}
|
||||
|
||||
function exactCanonicalLogin(value: string): boolean {
|
||||
return value.normalize('NFKC') === value && LOGIN.test(value);
|
||||
}
|
||||
|
||||
async function boundedJson(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (!contentType.toLowerCase().startsWith('application/json')) {
|
||||
throw new Error('owner-unexpected-content-type');
|
||||
}
|
||||
const declared = response.headers.get('content-length');
|
||||
if (declared !== null) {
|
||||
const size = Number.parseInt(declared, 10);
|
||||
if (Number.isFinite(size) && size > MAX_BODY_BYTES) {
|
||||
throw new Error('owner-unexpected-provider-shape');
|
||||
}
|
||||
}
|
||||
const body = new Uint8Array(await response.arrayBuffer());
|
||||
if (body.byteLength > MAX_BODY_BYTES) throw new Error('owner-unexpected-provider-shape');
|
||||
try {
|
||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body));
|
||||
} catch {
|
||||
throw new Error('owner-unexpected-provider-shape');
|
||||
}
|
||||
}
|
||||
|
||||
async function readPublicIdentity(
|
||||
origin: string,
|
||||
identity: string,
|
||||
fetchImpl: OwnerFetch,
|
||||
): Promise<{ readonly status: number; readonly user: unknown }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(`${origin}/api/v1/users/${encodeURIComponent(identity)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'mosaic-brain-owner/1',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
throw new Error('owner-provider-unavailable');
|
||||
}
|
||||
return { status: response.status, user: await boundedJson(response) };
|
||||
}
|
||||
|
||||
function publicIdentityMatches(value: unknown, identity: string): boolean {
|
||||
const parsed = providerUserSchema.safeParse(value);
|
||||
return parsed.success && parsed.data.login === identity;
|
||||
}
|
||||
|
||||
export interface BrainOwnerPolicyBinding {
|
||||
readonly brainNamespace: string;
|
||||
readonly publicControl: string;
|
||||
readonly privateControl: string;
|
||||
readonly standingQueue: string;
|
||||
}
|
||||
|
||||
export function resolveBrainOwnerPolicy(
|
||||
ownerPolicySource: string,
|
||||
estate: string,
|
||||
): BrainOwnerPolicyBinding | undefined {
|
||||
let rawPolicy: unknown;
|
||||
try {
|
||||
rawPolicy = JSON.parse(ownerPolicySource);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const policy = ownerPolicySchema.safeParse(rawPolicy);
|
||||
if (!policy.success) return undefined;
|
||||
const estatePolicies = policy.data.estates.filter(
|
||||
(candidate): boolean => candidate.estate === estate,
|
||||
);
|
||||
if (estatePolicies.length !== 1) return undefined;
|
||||
const estatePolicy = estatePolicies[0];
|
||||
if (estatePolicy === undefined || estatePolicy.laneArchiveOwners.length !== 1) return undefined;
|
||||
const brainNamespace = estatePolicy.laneArchiveOwners[0]?.login;
|
||||
if (brainNamespace === undefined || !exactCanonicalLogin(brainNamespace)) return undefined;
|
||||
return {
|
||||
brainNamespace,
|
||||
publicControl: estatePolicy.controls.publicIdentity,
|
||||
privateControl: estatePolicy.controls.privateIdentity,
|
||||
standingQueue: estatePolicy.standingProcess.queue,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRequestedOwner(requestedOwner: string): string | null {
|
||||
if (requestedOwner.normalize('NFKC') !== requestedOwner) return null;
|
||||
const match = REQUESTED_OWNER.exec(requestedOwner);
|
||||
const login = match?.[1];
|
||||
if (login === undefined || !exactCanonicalLogin(login)) return null;
|
||||
return login;
|
||||
}
|
||||
|
||||
export async function resolveProviderDurableOwner(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly host: string;
|
||||
readonly requestedOwner: string;
|
||||
},
|
||||
dependencies: {
|
||||
readonly fetch: OwnerFetch;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<MigrationOwnerResolution> {
|
||||
const requestedLogin = parseRequestedOwner(input.requestedOwner);
|
||||
if (requestedLogin === null) return refused('owner-name-invalid');
|
||||
|
||||
const target = parseCredentialEstateRegistry(input.estateRegistrySource).resolveByHost(
|
||||
input.host,
|
||||
);
|
||||
if (target === undefined) return refused('estate-host-unmapped');
|
||||
|
||||
const policy = resolveBrainOwnerPolicy(input.ownerPolicySource, target.estate);
|
||||
if (policy === undefined) return refused('owner-policy-invalid');
|
||||
if (policy.brainNamespace !== requestedLogin) return refused('owner-not-allowlisted');
|
||||
|
||||
const publicControl = policy.publicControl;
|
||||
const privateControl = policy.privateControl;
|
||||
const absentControl = dependencies.absentControlName();
|
||||
if (
|
||||
!exactCanonicalLogin(publicControl) ||
|
||||
!exactCanonicalLogin(privateControl) ||
|
||||
!exactCanonicalLogin(absentControl) ||
|
||||
new Set([publicControl, privateControl, absentControl, requestedLogin]).size !== 4
|
||||
) {
|
||||
return refused('owner-policy-invalid');
|
||||
}
|
||||
|
||||
try {
|
||||
const publicResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
publicControl,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (publicResult.status !== 200 || !publicIdentityMatches(publicResult.user, publicControl)) {
|
||||
return unresolved('owner-control-invalid');
|
||||
}
|
||||
|
||||
const privateResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
privateControl,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (privateResult.status !== 404) return unresolved('owner-control-invalid');
|
||||
|
||||
const absentResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
absentControl,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (absentResult.status !== 404) return unresolved('owner-control-invalid');
|
||||
|
||||
const ownerResult = await readPublicIdentity(
|
||||
target.host.apiBaseUrl,
|
||||
requestedLogin,
|
||||
dependencies.fetch,
|
||||
);
|
||||
if (ownerResult.status === 401 || ownerResult.status === 403 || ownerResult.status === 404) {
|
||||
return unresolved('owner-not-resolvable');
|
||||
}
|
||||
if (ownerResult.status !== 200) return unresolved('owner-provider-unavailable');
|
||||
if (!publicIdentityMatches(ownerResult.user, requestedLogin)) {
|
||||
return unresolved('owner-provider-identity-mismatch');
|
||||
}
|
||||
return {
|
||||
verdict: 'resolved',
|
||||
reasonCode: 'owner-verified',
|
||||
principal: { name: `user:${requestedLogin}`, kind: 'durable-human' },
|
||||
authority: {
|
||||
system: 'gitea',
|
||||
endpoint: `GET /api/v1/users/${requestedLogin}`,
|
||||
contentType: 'application/json',
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const reason = error instanceof Error ? error.message : 'owner-provider-unavailable';
|
||||
if (reason === 'owner-unexpected-content-type') return unresolved(reason);
|
||||
if (reason === 'owner-unexpected-provider-shape') return unresolved(reason);
|
||||
return unresolved('owner-provider-unavailable');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface BrainProvisionCommandModule {
|
||||
readonly BRAIN_PROVISION_COMMAND: string;
|
||||
registerBrainProvisionCommand(program: Command): void;
|
||||
executeBrainProvisionCommand(
|
||||
options: {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly targetUrl: string;
|
||||
readonly owner: string;
|
||||
readonly lane: string;
|
||||
readonly sourceRoot?: string;
|
||||
readonly brainRoot?: string;
|
||||
readonly ownerPolicy?: string;
|
||||
readonly registry?: string;
|
||||
},
|
||||
dependencies: {
|
||||
readonly run: () => never;
|
||||
readonly fetch: typeof fetch;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<{
|
||||
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||
readonly reasonCode: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-provision-command.js';
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadCommand(requirement: string): Promise<BrainProvisionCommandModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as BrainProvisionCommandModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: brain provision command is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-command-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('internal P7 brain provision command', (): void => {
|
||||
it('registers only explicit non-secret contract inputs and no credential/token lookup switches', async (): Promise<void> => {
|
||||
const module = await loadCommand('MB-REQ-03 broker-only provision command');
|
||||
const program = new Command();
|
||||
module.registerBrainProvisionCommand(program);
|
||||
const command = program.commands.find(
|
||||
(candidate) => candidate.name() === module.BRAIN_PROVISION_COMMAND,
|
||||
);
|
||||
|
||||
expect(command).toBeDefined();
|
||||
const flags = command?.options.map((option) => option.flags) ?? [];
|
||||
expect(flags.join(' ')).toContain('--identity');
|
||||
expect(flags.join(' ')).toContain('--target-url');
|
||||
expect(flags.join(' ')).toContain('--refusal-identity');
|
||||
expect(flags.join(' ')).toContain('--owner-policy');
|
||||
expect(flags.join(' ')).toContain('--owner');
|
||||
expect(flags.join(' ')).toContain('--lane');
|
||||
expect(flags.join(' ')).not.toMatch(/token|password|authorization|grant-authority/i);
|
||||
});
|
||||
|
||||
it('is registered by the shipped CLI', async (): Promise<void> => {
|
||||
const module = await loadCommand('MB-REQ-10 shipped P7 command');
|
||||
const cli = readFileSync(join(process.cwd(), 'src', 'cli.ts'), 'utf8');
|
||||
|
||||
expect(cli).toContain('registerBrainProvisionCommand');
|
||||
expect(cli).toContain(`registerBrainProvisionCommand(program)`);
|
||||
expect(module.BRAIN_PROVISION_COMMAND).toBe('__brain-provision');
|
||||
});
|
||||
|
||||
it('fails closed before commands when the local owner policy is absent', async (): Promise<void> => {
|
||||
const module = await loadCommand('MB-REQ-09 owner policy required');
|
||||
const root = tempRoot();
|
||||
const home = join(root, 'home');
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
mkdirSync(join(mosaicHome, 'cred'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(mosaicHome, 'cred', 'estates.json'),
|
||||
JSON.stringify({ version: 1, estates: [] }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
let commands = 0;
|
||||
|
||||
const result = await module.executeBrainProvisionCommand(
|
||||
{
|
||||
mosaicHome,
|
||||
home,
|
||||
identity: 'seat-a',
|
||||
refusalIdentity: 'outside-seat',
|
||||
targetUrl: 'https://git.example.invalid/example/stack.git',
|
||||
owner: 'user:durable-owner',
|
||||
lane: 'lane-a',
|
||||
},
|
||||
{
|
||||
run: (): never => {
|
||||
commands += 1;
|
||||
throw new Error('must not run');
|
||||
},
|
||||
fetch,
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'failed', reasonCode: 'owner-policy-unavailable' });
|
||||
expect(commands).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||
import { provisionBrain, type ProvisionResult } from './brain-provision.js';
|
||||
import { systemCommandRunner, type CommandRunner } from './brain-store-runtime.js';
|
||||
import type { OwnerFetch } from './brain-owner-resolver.js';
|
||||
|
||||
const MAX_POLICY_BYTES = 256 * 1024;
|
||||
export const BRAIN_PROVISION_COMMAND = '__brain-provision';
|
||||
|
||||
interface BrainProvisionCommandOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly home: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly targetUrl: string;
|
||||
readonly owner: string;
|
||||
readonly lane: string;
|
||||
readonly sourceRoot?: string;
|
||||
readonly brainRoot?: string;
|
||||
readonly ownerPolicy?: string;
|
||||
readonly registry?: string;
|
||||
}
|
||||
|
||||
interface BrainProvisionCommandDependencies {
|
||||
readonly run: CommandRunner;
|
||||
readonly fetch: OwnerFetch;
|
||||
readonly absentControlName: () => string;
|
||||
}
|
||||
|
||||
function configFailure(reasonCode: string): ProvisionResult {
|
||||
return {
|
||||
status: 'failed',
|
||||
reasonCode,
|
||||
findings: [{ code: `brain-${reasonCode}`, reasonCode }],
|
||||
owner: null,
|
||||
migration: null,
|
||||
};
|
||||
}
|
||||
|
||||
function readConfig(path: string, root: string): string {
|
||||
const content = readRegularFileSecure(path, { root, maxBytes: MAX_POLICY_BYTES }).content;
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(content);
|
||||
}
|
||||
|
||||
export async function executeBrainProvisionCommand(
|
||||
options: BrainProvisionCommandOptions,
|
||||
dependencies: BrainProvisionCommandDependencies,
|
||||
): Promise<ProvisionResult> {
|
||||
const registry = options.registry ?? join(options.mosaicHome, 'cred', 'estates.json');
|
||||
const ownerPolicy = options.ownerPolicy ?? join(options.mosaicHome, 'brain', 'owners.json');
|
||||
let estateRegistrySource: string;
|
||||
try {
|
||||
estateRegistrySource = readConfig(registry, options.mosaicHome);
|
||||
} catch {
|
||||
return configFailure('estate-registry-unavailable');
|
||||
}
|
||||
let ownerPolicySource: string;
|
||||
try {
|
||||
ownerPolicySource = readConfig(ownerPolicy, options.mosaicHome);
|
||||
} catch {
|
||||
return configFailure('owner-policy-unavailable');
|
||||
}
|
||||
|
||||
try {
|
||||
return await provisionBrain(
|
||||
{
|
||||
estateRegistrySource,
|
||||
ownerPolicySource,
|
||||
targetGitUrl: options.targetUrl,
|
||||
requestedOwner: options.owner,
|
||||
identity: options.identity,
|
||||
refusalIdentity: options.refusalIdentity,
|
||||
root: options.brainRoot ?? join(options.home, '.mosaic'),
|
||||
sourceRoot: options.sourceRoot ?? join(options.mosaicHome, 'memory'),
|
||||
seat: options.identity,
|
||||
lane: options.lane,
|
||||
laneActive: false,
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
} catch {
|
||||
return configFailure('brain-provision-exception');
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBrainProvisionCommand(program: Command): void {
|
||||
program
|
||||
.command(BRAIN_PROVISION_COMMAND, { hidden: true })
|
||||
.description('Internal installer P7 durable-brain provisioner')
|
||||
.requiredOption('--identity <name>', 'explicit fleet identity')
|
||||
.requiredOption('--target-url <url>', 'configured target git URL')
|
||||
.requiredOption('--refusal-identity <name>', 'explicit out-of-estate negative control')
|
||||
.requiredOption('--owner <owner>', 'policy-bound durable owner candidate')
|
||||
.requiredOption('--lane <name>', 'source lane to migrate')
|
||||
.option('--mosaic-home <path>', 'installed Mosaic home')
|
||||
.option('--home <path>', 'principal home')
|
||||
.option('--source-root <path>', 'legacy memory root')
|
||||
.option('--brain-root <path>', 'per-estate brain checkout root')
|
||||
.option('--owner-policy <path>', 'durable-owner allowlist policy')
|
||||
.option('--registry <path>', 'estate registry path')
|
||||
.action(async (raw: Record<string, string | undefined>): Promise<void> => {
|
||||
const home = raw['home'] ?? homedir();
|
||||
const mosaicHome =
|
||||
raw['mosaicHome'] ?? process.env['MOSAIC_HOME'] ?? join(home, '.config', 'mosaic');
|
||||
const result = await executeBrainProvisionCommand(
|
||||
{
|
||||
mosaicHome,
|
||||
home,
|
||||
identity: raw['identity']!,
|
||||
targetUrl: raw['targetUrl']!,
|
||||
refusalIdentity: raw['refusalIdentity']!,
|
||||
owner: raw['owner']!,
|
||||
lane: raw['lane']!,
|
||||
...(raw['sourceRoot'] === undefined ? {} : { sourceRoot: raw['sourceRoot'] }),
|
||||
...(raw['brainRoot'] === undefined ? {} : { brainRoot: raw['brainRoot'] }),
|
||||
...(raw['ownerPolicy'] === undefined ? {} : { ownerPolicy: raw['ownerPolicy'] }),
|
||||
...(raw['registry'] === undefined ? {} : { registry: raw['registry'] }),
|
||||
},
|
||||
{
|
||||
run: systemCommandRunner,
|
||||
fetch,
|
||||
absentControlName: (): string => `mosaic-absent-${randomUUID()}`,
|
||||
},
|
||||
);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
status: result.status,
|
||||
reasonCode: result.reasonCode,
|
||||
findings: result.findings,
|
||||
owner: result.owner,
|
||||
migration: result.migration,
|
||||
})}\n`,
|
||||
);
|
||||
if (result.status !== 'provisioned') process.exitCode = result.status === 'blocked' ? 30 : 20;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
interface CommandRequest {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly status: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
type CommandRunner = (request: CommandRequest) => CommandResult;
|
||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface ProvisionResult {
|
||||
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||
readonly reasonCode: string;
|
||||
readonly findings: readonly { code: string; reasonCode: string | null }[];
|
||||
readonly owner: {
|
||||
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||||
readonly reasonCode: string;
|
||||
} | null;
|
||||
readonly migration: {
|
||||
readonly status: 'migrated' | 'reported' | 'failed';
|
||||
readonly reported: readonly { path: string; reason: string }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ProvisionModule {
|
||||
provisionBrain(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly requestedOwner: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly root: string;
|
||||
readonly sourceRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
},
|
||||
dependencies: {
|
||||
readonly run: CommandRunner;
|
||||
readonly fetch: FetchLike;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<ProvisionResult>;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-provision.js';
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadProvisioner(requirement: string): Promise<ProvisionModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as ProvisionModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: brain provisioner is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-provision-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function estateRegistry(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.example.invalid',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.example.invalid',
|
||||
tokenPrefix: 'gitea-example',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function ownerPolicy(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
estate: 'homelab',
|
||||
laneArchiveOwners: [{ kind: 'provider-user', login: 'durable-owner' }],
|
||||
standingProcess: { kind: 'glpi-queue', queue: 'mosaic-brain-remediation' },
|
||||
controls: { publicIdentity: 'public-control', privateIdentity: 'private-control' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function validateResult(
|
||||
outcome: 'ok' | 'refused' | 'indeterminate',
|
||||
reasonCode: string,
|
||||
identity = 'seat-a',
|
||||
): string {
|
||||
const exitCode = outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30;
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation: 'validate',
|
||||
outcome,
|
||||
exitCode,
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity,
|
||||
estate: 'homelab',
|
||||
host: 'git.example.invalid',
|
||||
repo: 'durable-owner/mosaic-brain',
|
||||
},
|
||||
mutation: 'none',
|
||||
reason: { code: reasonCode, message: 'non-secret' },
|
||||
evidence: {
|
||||
providerIdentity:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
login: identity,
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
repositoryPermission:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
requested: 'write',
|
||||
effective: 'write',
|
||||
endpoint: 'GET /api/v1/repos/durable-owner/mosaic-brain',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
writeDifferential:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
state: 'can-write',
|
||||
credentialBinding: 'same-resolution',
|
||||
transportPrincipal: identity,
|
||||
authenticatedReceivePack: 'advertised',
|
||||
readOnlyControl: {
|
||||
identity: 'read-control',
|
||||
providerPermission: 'read',
|
||||
receivePack: 'refused',
|
||||
},
|
||||
unauthenticatedReceivePack: 'refused',
|
||||
artifactCreated: false,
|
||||
proves: 'non-secret evidence',
|
||||
doesNotProve: 'branch update acceptance',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
audit: { journalId: 'opaque', state: 'sealed' },
|
||||
});
|
||||
}
|
||||
|
||||
function publicUser(login: string): Response {
|
||||
return new Response(JSON.stringify({ id: 1, login, visibility: 'public', active: false }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function ownerFetch(ownerStatus = 200): FetchLike {
|
||||
return async (input): Promise<Response> => {
|
||||
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
const identity = decodeURIComponent(new URL(raw).pathname.split('/').at(-1) ?? '');
|
||||
if (identity === 'public-control') return publicUser(identity);
|
||||
if (identity === 'private-control' || identity === 'generated-absent-control') {
|
||||
return new Response(JSON.stringify({ message: 'hidden or absent' }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (identity === 'durable-owner' && ownerStatus === 200) return publicUser(identity);
|
||||
return new Response(JSON.stringify({ message: 'hidden or absent' }), {
|
||||
status: ownerStatus,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function baseInput(root: string): {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly requestedOwner: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly root: string;
|
||||
readonly sourceRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
} {
|
||||
return {
|
||||
estateRegistrySource: estateRegistry(),
|
||||
ownerPolicySource: ownerPolicy(),
|
||||
targetGitUrl: 'https://git.example.invalid/example/stack.git',
|
||||
requestedOwner: 'user:durable-owner',
|
||||
identity: 'seat-a',
|
||||
refusalIdentity: 'outside-seat',
|
||||
root: join(root, 'brain'),
|
||||
sourceRoot: join(root, 'local-memory'),
|
||||
seat: 'seat-a',
|
||||
lane: 'lane-a',
|
||||
laneActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('P7 brain provisioning orchestration', (): void => {
|
||||
it('requires the P5 write-capability postcondition and never grants or clones on refusal', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-10 P5 before P7');
|
||||
const root = tempRoot();
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = await provisioner.provisionBrain(baseInput(root), {
|
||||
run: (request): CommandResult => {
|
||||
requests.push(request);
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
},
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'credential-postcondition-failed',
|
||||
});
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.program).toBe('mosaic');
|
||||
expect(requests[0]?.args.slice(0, 3)).toEqual(['cred', 'validate', 'seat-a']);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
expect(requests.some((request) => request.args.includes('clone'))).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks before clone when the out-of-estate Git and API axes disagree', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-05 P7 refusal control gate');
|
||||
const root = tempRoot();
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = await provisioner.provisionBrain(baseInput(root), {
|
||||
run: (request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic' && request.args[2] === 'outside-seat') {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (request.program === 'mosaic') {
|
||||
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
if (request.args.includes('ls-remote')) {
|
||||
return { status: 0, stdout: 'refs are visible', stderr: '' };
|
||||
}
|
||||
return { status: 99, stdout: '', stderr: 'unexpected command' };
|
||||
},
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'refusal-control-failed',
|
||||
});
|
||||
expect(requests.some((request) => request.args.includes('clone'))).toBe(false);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
});
|
||||
|
||||
it('clones, seeds, resolves owner, migrates, pushes on each write, and archives source only after reachability', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-07 complete migration transaction');
|
||||
const root = tempRoot();
|
||||
const input = baseInput(root);
|
||||
mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||
writeFileSync(source, 'durable finding\n');
|
||||
const requests: CommandRequest[] = [];
|
||||
let commitOrdinal = 0;
|
||||
const runner: CommandRunner = (request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic') {
|
||||
if (request.args[2] === 'outside-seat') {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('ls-remote')) {
|
||||
return {
|
||||
status: 128,
|
||||
stdout: '',
|
||||
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (request.args[0] === 'clone') {
|
||||
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('remote get-url origin')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('branch --show-current')) {
|
||||
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('status --porcelain')) {
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse HEAD')) {
|
||||
commitOrdinal += 1;
|
||||
return {
|
||||
status: 0,
|
||||
stdout: `${commitOrdinal === 1 ? 'a' : 'c'.repeat(1)}`.repeat(40) + '\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse origin/main')) {
|
||||
const value = commitOrdinal === 1 ? 'b' : 'd';
|
||||
return { status: 0, stdout: `${value.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
|
||||
const result = await provisioner.provisionBrain(input, {
|
||||
run: runner,
|
||||
fetch: ownerFetch(),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'provisioned',
|
||||
reasonCode: 'brain-provisioned',
|
||||
owner: { verdict: 'resolved', reasonCode: 'owner-verified' },
|
||||
migration: { status: 'migrated' },
|
||||
});
|
||||
expect(existsSync(source)).toBe(false);
|
||||
const imported = result.migration?.reported ?? [];
|
||||
expect(imported).toEqual([]);
|
||||
const laneImports = join(input.root, 'lanes', 'lane-a', 'findings', 'imports');
|
||||
const archiveImports = join(input.root, 'archives', 'imports', 'lane');
|
||||
expect(existsSync(laneImports)).toBe(true);
|
||||
expect(existsSync(archiveImports)).toBe(true);
|
||||
expect(
|
||||
requests.filter((request) => request.program === 'git' && request.args.includes('push')),
|
||||
).toHaveLength(2);
|
||||
expect(requests.some((request) => request.args.includes('grant'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps every source and reports the owner ambiguity when the public owner cannot be resolved', async (): Promise<void> => {
|
||||
const provisioner = await loadProvisioner('MB-REQ-07 owner-blocked detection/reporting');
|
||||
const root = tempRoot();
|
||||
const input = baseInput(root);
|
||||
mkdirSync(input.root, { recursive: true });
|
||||
mkdirSync(join(input.root, '.git'), { recursive: true });
|
||||
mkdirSync(join(input.sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
const source = join(input.sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||
writeFileSync(source, 'retain me\n');
|
||||
let commitOrdinal = 0;
|
||||
|
||||
const result = await provisioner.provisionBrain(input, {
|
||||
run: (request): CommandResult => {
|
||||
if (request.program === 'mosaic') {
|
||||
if (request.args[2] === 'outside-seat') {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity', 'outside-seat'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
return { status: 0, stdout: validateResult('ok', 'validation-verified'), stderr: '' };
|
||||
}
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('ls-remote')) {
|
||||
return {
|
||||
status: 128,
|
||||
stdout: '',
|
||||
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('remote get-url origin')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'https://git.example.invalid/durable-owner/mosaic-brain.git\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('branch --show-current')) {
|
||||
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('status --porcelain')) {
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse HEAD')) {
|
||||
commitOrdinal += 1;
|
||||
return { status: 0, stdout: `${'a'.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse origin/main')) {
|
||||
return { status: 0, stdout: `${'b'.repeat(40)}\n`, stderr: '' };
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
fetch: ownerFetch(404),
|
||||
absentControlName: (): string => 'generated-absent-control',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'blocked',
|
||||
reasonCode: 'owner-not-resolvable',
|
||||
owner: { verdict: 'not-measured', reasonCode: 'owner-not-resolvable' },
|
||||
migration: { status: 'reported' },
|
||||
});
|
||||
expect(readFileSync(source, 'utf8')).toBe('retain me\n');
|
||||
expect(result.migration?.reported).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: source, reason: expect.stringMatching(/owner/i) }),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(result)).not.toMatch(/owner-not-found|does-not-exist/);
|
||||
expect(commitOrdinal).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { lstatSync } from 'node:fs';
|
||||
import { parseRequestedOwner, resolveProviderDurableOwner } from './brain-owner-resolver.js';
|
||||
import {
|
||||
createBrainSkeleton,
|
||||
deriveBrainTarget,
|
||||
discoverBrainMigration,
|
||||
migrateBrainState,
|
||||
type MigrationResult,
|
||||
type MigrationOwnerResolution,
|
||||
} from './brain-store.js';
|
||||
import {
|
||||
collectBrainDoctorReport,
|
||||
collectBrainRefusalControl,
|
||||
publishBrainPaths,
|
||||
type CommandRequest,
|
||||
type CommandResult,
|
||||
type CommandRunner,
|
||||
} from './brain-store-runtime.js';
|
||||
import type { OwnerFetch } from './brain-owner-resolver.js';
|
||||
|
||||
export interface ProvisionResult {
|
||||
readonly status: 'provisioned' | 'blocked' | 'failed';
|
||||
readonly reasonCode: string;
|
||||
readonly findings: readonly {
|
||||
readonly code: string;
|
||||
readonly reasonCode: string | null;
|
||||
}[];
|
||||
readonly owner: Pick<MigrationOwnerResolution, 'verdict' | 'reasonCode'> | null;
|
||||
readonly migration: Pick<MigrationResult, 'status' | 'reported'> | null;
|
||||
}
|
||||
|
||||
function commandEnv(identity: string): Readonly<Record<string, string>> {
|
||||
return { MOSAIC_GIT_IDENTITY: identity, GIT_TERMINAL_PROMPT: '0' };
|
||||
}
|
||||
|
||||
function findingView(
|
||||
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||
): readonly { readonly code: string; readonly reasonCode: string | null }[] {
|
||||
return findings.map((finding): { readonly code: string; readonly reasonCode: string | null } => ({
|
||||
code: finding.code,
|
||||
reasonCode: finding.reasonCode,
|
||||
}));
|
||||
}
|
||||
|
||||
function blocked(
|
||||
reasonCode: string,
|
||||
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||
owner: MigrationOwnerResolution | null = null,
|
||||
migration: MigrationResult | null = null,
|
||||
): ProvisionResult {
|
||||
return {
|
||||
status: 'blocked',
|
||||
reasonCode,
|
||||
findings: findingView(findings),
|
||||
owner: owner === null ? null : { verdict: owner.verdict, reasonCode: owner.reasonCode },
|
||||
migration:
|
||||
migration === null ? null : { status: migration.status, reported: migration.reported },
|
||||
};
|
||||
}
|
||||
|
||||
function failed(
|
||||
reasonCode: string,
|
||||
findings: readonly { readonly code: string; readonly reasonCode: string | null }[],
|
||||
owner: MigrationOwnerResolution | null = null,
|
||||
migration: MigrationResult | null = null,
|
||||
): ProvisionResult {
|
||||
return {
|
||||
...blocked(reasonCode, findings, owner, migration),
|
||||
status: 'failed',
|
||||
};
|
||||
}
|
||||
|
||||
export async function provisionBrain(
|
||||
input: {
|
||||
readonly estateRegistrySource: string;
|
||||
readonly ownerPolicySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly requestedOwner: string;
|
||||
readonly identity: string;
|
||||
readonly refusalIdentity: string;
|
||||
readonly root: string;
|
||||
readonly sourceRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
},
|
||||
dependencies: {
|
||||
readonly run: CommandRunner;
|
||||
readonly fetch: OwnerFetch;
|
||||
readonly absentControlName: () => string;
|
||||
},
|
||||
): Promise<ProvisionResult> {
|
||||
const brainNamespace = parseRequestedOwner(input.requestedOwner);
|
||||
if (brainNamespace === null) return blocked('owner-name-invalid', []);
|
||||
const target = deriveBrainTarget(input.estateRegistrySource, input.targetGitUrl, brainNamespace);
|
||||
const doctorInput = {
|
||||
registrySource: input.estateRegistrySource,
|
||||
targetGitUrl: input.targetGitUrl,
|
||||
brainNamespace,
|
||||
identity: input.identity,
|
||||
root: input.root,
|
||||
};
|
||||
let report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||
if (report.access.outcome !== 'ok') {
|
||||
return blocked('credential-postcondition-failed', report.findings);
|
||||
}
|
||||
|
||||
const refusalControl = collectBrainRefusalControl(
|
||||
{
|
||||
registrySource: input.estateRegistrySource,
|
||||
targetGitUrl: input.targetGitUrl,
|
||||
brainNamespace,
|
||||
refusalIdentity: input.refusalIdentity,
|
||||
},
|
||||
dependencies.run,
|
||||
);
|
||||
if (!refusalControl.observed) {
|
||||
return blocked('refusal-control-failed', [
|
||||
...report.findings,
|
||||
{
|
||||
code: 'brain-refusal-control-indeterminate',
|
||||
reasonCode: refusalControl.reasonCode,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const owner = await resolveProviderDurableOwner(
|
||||
{
|
||||
estateRegistrySource: input.estateRegistrySource,
|
||||
ownerPolicySource: input.ownerPolicySource,
|
||||
host: target.host,
|
||||
requestedOwner: input.requestedOwner,
|
||||
},
|
||||
{
|
||||
fetch: dependencies.fetch,
|
||||
absentControlName: dependencies.absentControlName,
|
||||
},
|
||||
);
|
||||
if (owner.verdict !== 'resolved') {
|
||||
const plan = discoverBrainMigration(
|
||||
{
|
||||
sourceRoot: input.sourceRoot,
|
||||
brainRoot: input.root,
|
||||
seat: input.seat,
|
||||
lane: input.lane,
|
||||
laneActive: input.laneActive,
|
||||
},
|
||||
(): MigrationOwnerResolution => owner,
|
||||
);
|
||||
const migration = migrateBrainState(
|
||||
plan,
|
||||
(): never => {
|
||||
throw new Error('blocked owner cannot publish');
|
||||
},
|
||||
input.root,
|
||||
);
|
||||
return blocked(owner.reasonCode, report.findings, owner, migration);
|
||||
}
|
||||
|
||||
if (report.findings.some((finding): boolean => finding.code === 'brain-clone-missing')) {
|
||||
const clone: CommandRequest = {
|
||||
program: 'git',
|
||||
args: ['clone', '--branch', 'main', '--single-branch', target.cloneUrl, input.root],
|
||||
env: commandEnv(input.identity),
|
||||
};
|
||||
const cloneResult: CommandResult = dependencies.run(clone);
|
||||
if (cloneResult.status !== 0) return failed('brain-clone-failed', report.findings);
|
||||
report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||
}
|
||||
|
||||
const blockingCloneFindings = report.findings.filter(
|
||||
(finding): boolean =>
|
||||
finding.code === 'brain-clone-missing' ||
|
||||
finding.code === 'brain-not-git-repository' ||
|
||||
finding.code === 'brain-remote-mismatch' ||
|
||||
finding.code === 'brain-branch-mismatch' ||
|
||||
finding.code.startsWith('brain-write-access-'),
|
||||
);
|
||||
if (blockingCloneFindings.length > 0) {
|
||||
return blocked('brain-postcondition-failed', report.findings);
|
||||
}
|
||||
|
||||
let skeletonPaths: readonly string[];
|
||||
try {
|
||||
const skeleton = createBrainSkeleton(input.root);
|
||||
skeletonPaths = skeleton.created.filter((path: string): boolean => lstatSync(path).isFile());
|
||||
} catch {
|
||||
return failed('brain-skeleton-failed', report.findings);
|
||||
}
|
||||
if (skeletonPaths.length > 0) {
|
||||
try {
|
||||
const evidence = publishBrainPaths(
|
||||
{
|
||||
root: input.root,
|
||||
identity: input.identity,
|
||||
paths: skeletonPaths,
|
||||
message: 'chore: seed durable brain layout',
|
||||
},
|
||||
dependencies.run,
|
||||
);
|
||||
if (!evidence.reachable) return failed('brain-skeleton-not-reachable', report.findings);
|
||||
} catch {
|
||||
return failed('brain-skeleton-publish-failed', report.findings);
|
||||
}
|
||||
}
|
||||
|
||||
const plan = discoverBrainMigration(
|
||||
{
|
||||
sourceRoot: input.sourceRoot,
|
||||
brainRoot: input.root,
|
||||
seat: input.seat,
|
||||
lane: input.lane,
|
||||
laneActive: input.laneActive,
|
||||
},
|
||||
(): MigrationOwnerResolution => owner,
|
||||
);
|
||||
const migration = migrateBrainState(
|
||||
plan,
|
||||
(brainRoot: string, paths: readonly string[]) =>
|
||||
publishBrainPaths(
|
||||
{
|
||||
root: brainRoot,
|
||||
identity: input.identity,
|
||||
paths,
|
||||
message: `migrate: archive ${input.lane} working memory`,
|
||||
},
|
||||
dependencies.run,
|
||||
),
|
||||
input.root,
|
||||
);
|
||||
|
||||
if (migration.status === 'failed') {
|
||||
return failed('brain-migration-publish-failed', report.findings, owner, migration);
|
||||
}
|
||||
|
||||
report = collectBrainDoctorReport(doctorInput, dependencies.run);
|
||||
if (report.findings.length > 0) {
|
||||
return blocked('brain-final-postcondition-failed', report.findings, owner, migration);
|
||||
}
|
||||
return {
|
||||
status: 'provisioned',
|
||||
reasonCode: 'brain-provisioned',
|
||||
findings: [],
|
||||
owner: { verdict: owner.verdict, reasonCode: owner.reasonCode },
|
||||
migration: { status: migration.status, reported: migration.reported },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Red-first integration seam for #1051.
|
||||
*
|
||||
* Live broker grants and remote writes are deliberately not exercised here.
|
||||
* The injected runner records exact commands and returns contract objects; the
|
||||
* production path must call `mosaic cred`, never resolve a token itself.
|
||||
*/
|
||||
|
||||
interface CommandRequest {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly cwd?: string;
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
interface CommandResult {
|
||||
readonly status: number;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
}
|
||||
|
||||
type CommandRunner = (request: CommandRequest) => CommandResult;
|
||||
|
||||
interface DoctorRuntimeReport {
|
||||
readonly findings: readonly { code: string; repairable: boolean; reasonCode: string | null }[];
|
||||
readonly access: {
|
||||
readonly outcome: 'ok' | 'refused' | 'error' | 'indeterminate';
|
||||
readonly exitCode: 0 | 10 | 20 | 30;
|
||||
readonly reasonCode: string;
|
||||
};
|
||||
readonly refusalControl: {
|
||||
readonly observed: boolean;
|
||||
readonly reasonCode: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PublishEvidence {
|
||||
readonly commit: string;
|
||||
readonly remoteHead: string;
|
||||
readonly reachable: boolean;
|
||||
}
|
||||
|
||||
interface BrainRuntimeModule {
|
||||
collectBrainDoctorReport(
|
||||
input: {
|
||||
readonly registrySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly brainNamespace: string;
|
||||
readonly identity: string;
|
||||
readonly root: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): DoctorRuntimeReport;
|
||||
repairBrainDoctor(
|
||||
input: {
|
||||
readonly registrySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly brainNamespace: string;
|
||||
readonly identity: string;
|
||||
readonly root: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): DoctorRuntimeReport;
|
||||
collectBrainRefusalControl(
|
||||
input: {
|
||||
readonly registrySource: string;
|
||||
readonly targetGitUrl: string;
|
||||
readonly brainNamespace: string;
|
||||
readonly refusalIdentity: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): {
|
||||
readonly observed: boolean;
|
||||
readonly reasonCode: string | null;
|
||||
readonly gitReasonCode: string;
|
||||
readonly apiReasonCode: string;
|
||||
};
|
||||
publishBrainPaths(
|
||||
input: {
|
||||
readonly root: string;
|
||||
readonly identity: string;
|
||||
readonly paths: readonly string[];
|
||||
readonly message: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): PublishEvidence;
|
||||
}
|
||||
|
||||
const MODULE_PATH = './brain-store-runtime.js';
|
||||
const roots: string[] = [];
|
||||
|
||||
async function loadRuntime(requirement: string): Promise<BrainRuntimeModule> {
|
||||
try {
|
||||
return (await import(MODULE_PATH)) as BrainRuntimeModule;
|
||||
} catch (error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`${requirement}: brain-store runtime is absent (${detail})`);
|
||||
}
|
||||
}
|
||||
|
||||
function tempRoot(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-brain-runtime-'));
|
||||
roots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function registry(): string {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
estates: [
|
||||
{
|
||||
name: 'homelab',
|
||||
readOnlyControlIdentity: 'read-control',
|
||||
hosts: [
|
||||
{
|
||||
host: 'git.mosaicstack.dev',
|
||||
provider: 'gitea',
|
||||
apiBaseUrl: 'https://git.mosaicstack.dev',
|
||||
tokenPrefix: 'gitea-mosaicstack',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function validateResult(
|
||||
outcome: 'ok' | 'refused' | 'error' | 'indeterminate',
|
||||
reasonCode: string,
|
||||
): string {
|
||||
const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const;
|
||||
return JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
operation: 'validate',
|
||||
outcome,
|
||||
exitCode: exits[outcome],
|
||||
retryable: false,
|
||||
subject: {
|
||||
identity: 'synthetic-no-token',
|
||||
estate: 'homelab',
|
||||
host: 'git.mosaicstack.dev',
|
||||
repo: 'mosaicstack/mosaic-brain',
|
||||
},
|
||||
mutation: 'none',
|
||||
reason: { code: reasonCode, message: 'non-secret' },
|
||||
evidence: {
|
||||
providerIdentity:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
login: 'synthetic-no-token',
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
repositoryPermission:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
requested: 'write',
|
||||
effective: 'write',
|
||||
endpoint: 'GET /api/v1/repos/mosaicstack/mosaic-brain',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
writeDifferential:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
state: 'can-write',
|
||||
credentialBinding: 'same-resolution',
|
||||
transportPrincipal: 'synthetic-no-token',
|
||||
authenticatedReceivePack: 'advertised',
|
||||
readOnlyControl: {
|
||||
identity: 'read-control',
|
||||
providerPermission: 'read',
|
||||
receivePack: 'refused',
|
||||
},
|
||||
unauthenticatedReceivePack: 'refused',
|
||||
artifactCreated: false,
|
||||
proves: 'non-secret evidence',
|
||||
doesNotProve: 'branch update acceptance',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
audit: { journalId: 'opaque', state: 'sealed' },
|
||||
});
|
||||
}
|
||||
|
||||
function requestHasSecretShape(request: CommandRequest): boolean {
|
||||
return JSON.stringify(request).match(/authorization|password|\.token|token-dir/i) !== null;
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('doctor runtime observation', (): void => {
|
||||
it('runs a synthetic no-token positive control through mosaic cred even when the clone is missing', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-05 synthetic refusal control');
|
||||
const root = join(tempRoot(), 'missing-brain');
|
||||
const requests: CommandRequest[] = [];
|
||||
const runner: CommandRunner = (request): CommandResult => {
|
||||
requests.push(request);
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
};
|
||||
|
||||
const report = runtime.collectBrainDoctorReport(
|
||||
{
|
||||
registrySource: registry(),
|
||||
targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
brainNamespace: 'mosaicstack',
|
||||
identity: 'synthetic-no-token',
|
||||
root,
|
||||
},
|
||||
runner,
|
||||
);
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]).toMatchObject({
|
||||
program: 'mosaic',
|
||||
args: [
|
||||
'cred',
|
||||
'validate',
|
||||
'synthetic-no-token',
|
||||
'--estate',
|
||||
'homelab',
|
||||
'--host',
|
||||
'git.mosaicstack.dev',
|
||||
'--repo',
|
||||
'mosaicstack/mosaic-brain',
|
||||
'--require',
|
||||
'write',
|
||||
'--json',
|
||||
],
|
||||
});
|
||||
expect(requests.some(requestHasSecretShape)).toBe(false);
|
||||
expect(report.refusalControl).toEqual({
|
||||
observed: true,
|
||||
reasonCode: 'no-token-for-identity',
|
||||
});
|
||||
expect(report.findings.map((finding) => finding.code)).toEqual(
|
||||
expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']),
|
||||
);
|
||||
});
|
||||
|
||||
it('treats process/object terminal-class disagreement as indeterminate', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-08 process/object disagreement');
|
||||
const root = join(tempRoot(), 'missing-brain');
|
||||
|
||||
const report = runtime.collectBrainDoctorReport(
|
||||
{
|
||||
registrySource: registry(),
|
||||
targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
brainNamespace: 'mosaicstack',
|
||||
identity: 'synthetic-no-token',
|
||||
root,
|
||||
},
|
||||
(): CommandResult => ({
|
||||
status: 0,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: '',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(report.access).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
exitCode: 30,
|
||||
reasonCode: 'unexpected-provider-shape',
|
||||
});
|
||||
expect(report.refusalControl.observed).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a self-consistent broker object for a different declared subject', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-04 caller subject binding');
|
||||
const root = join(tempRoot(), 'missing-brain');
|
||||
|
||||
const report = runtime.collectBrainDoctorReport(
|
||||
{
|
||||
registrySource: registry(),
|
||||
targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
brainNamespace: 'mosaicstack',
|
||||
identity: 'seat-a',
|
||||
root,
|
||||
},
|
||||
(): CommandResult => ({
|
||||
status: 0,
|
||||
stdout: validateResult('ok', 'validation-verified'),
|
||||
stderr: '',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(report.access).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
exitCode: 30,
|
||||
reasonCode: 'unexpected-provider-shape',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads wrong remote and dirty state from git while preserving credential identity binding', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-08 real git observation seam');
|
||||
const root = join(tempRoot(), 'brain');
|
||||
mkdirSync(join(root, '.git'), { recursive: true });
|
||||
const requests: CommandRequest[] = [];
|
||||
const runner: CommandRunner = (request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic') {
|
||||
const source = validateResult('ok', 'validation-verified').replaceAll(
|
||||
'synthetic-no-token',
|
||||
'seat-a',
|
||||
);
|
||||
return { status: 0, stdout: source, stderr: '' };
|
||||
}
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('remote get-url origin')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'https://git.uscllc.com/usc/mosaic-brain.git\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('branch --show-current')) {
|
||||
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('status --porcelain')) {
|
||||
return { status: 0, stdout: '?? uncommitted.md\n', stderr: '' };
|
||||
}
|
||||
return { status: 99, stdout: '', stderr: 'unexpected command' };
|
||||
};
|
||||
|
||||
const report = runtime.collectBrainDoctorReport(
|
||||
{
|
||||
registrySource: registry(),
|
||||
targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
brainNamespace: 'mosaicstack',
|
||||
identity: 'seat-a',
|
||||
root,
|
||||
},
|
||||
runner,
|
||||
);
|
||||
|
||||
expect(report.findings.map((finding) => finding.code)).toEqual(
|
||||
expect.arrayContaining(['brain-remote-mismatch', 'brain-uncommitted-state']),
|
||||
);
|
||||
expect(requests.filter((request) => request.program === 'git')).toHaveLength(4);
|
||||
for (const request of requests) {
|
||||
expect(request.env['MOSAIC_GIT_IDENTITY']).toBe('seat-a');
|
||||
expect(request.env['GIT_TERMINAL_PROMPT']).toBe('0');
|
||||
}
|
||||
});
|
||||
|
||||
it('repairs write refusal through mosaic cred, revalidates, then clones and verifies the resulting object', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-08 broker-only doctor repair');
|
||||
const root = join(tempRoot(), 'brain');
|
||||
const requests: CommandRequest[] = [];
|
||||
let validationCount = 0;
|
||||
const runner: CommandRunner = (request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic' && request.args[1] === 'validate') {
|
||||
validationCount += 1;
|
||||
if (validationCount === 1) {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity').replaceAll(
|
||||
'synthetic-no-token',
|
||||
'seat-a',
|
||||
),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
const source = validateResult('ok', 'validation-verified').replaceAll(
|
||||
'synthetic-no-token',
|
||||
'seat-a',
|
||||
);
|
||||
return { status: 0, stdout: source, stderr: '' };
|
||||
}
|
||||
if (request.program === 'mosaic' && request.args[1] === 'grant') {
|
||||
return { status: 0, stdout: '{"outcome":"ok"}\n', stderr: '' };
|
||||
}
|
||||
if (request.program === 'git' && request.args[0] === 'clone') {
|
||||
mkdirSync(join(root, '.git'), { recursive: true });
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('rev-parse --is-inside-work-tree')) {
|
||||
return { status: 0, stdout: 'true\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('remote get-url origin')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: 'https://git.mosaicstack.dev/mosaicstack/mosaic-brain.git\n',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('branch --show-current')) {
|
||||
return { status: 0, stdout: 'main\n', stderr: '' };
|
||||
}
|
||||
if (command.includes('status --porcelain')) {
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
return { status: 99, stdout: '', stderr: 'unexpected command' };
|
||||
};
|
||||
|
||||
const report = runtime.repairBrainDoctor(
|
||||
{
|
||||
registrySource: registry(),
|
||||
targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
brainNamespace: 'mosaicstack',
|
||||
identity: 'seat-a',
|
||||
root,
|
||||
},
|
||||
runner,
|
||||
);
|
||||
|
||||
expect(report.findings).toEqual([]);
|
||||
const sequence = requests.map(
|
||||
(request) => `${request.program}:${request.args[1] ?? request.args[0]}`,
|
||||
);
|
||||
expect(sequence.slice(0, 4)).toEqual([
|
||||
'mosaic:validate',
|
||||
'mosaic:grant',
|
||||
'mosaic:validate',
|
||||
'git:--branch',
|
||||
]);
|
||||
expect(requests.some(requestHasSecretShape)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not clone when broker revalidation remains refused after a grant attempt', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-08 failed grant remains visible');
|
||||
const root = join(tempRoot(), 'brain');
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const report = runtime.repairBrainDoctor(
|
||||
{
|
||||
registrySource: registry(),
|
||||
targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
brainNamespace: 'mosaicstack',
|
||||
identity: 'synthetic-no-token',
|
||||
root,
|
||||
},
|
||||
(request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic' && request.args[1] === 'grant') {
|
||||
return { status: 10, stdout: '{"outcome":"refused"}\n', stderr: 'refused' };
|
||||
}
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(report.findings.map((finding) => finding.code)).toEqual(
|
||||
expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']),
|
||||
);
|
||||
expect(requests.some((request) => request.program === 'git')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('R5 production refusal control', (): void => {
|
||||
it('requires matching refusal on both Git transport and broker API axes', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-05 production both-axis refusal');
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const result = runtime.collectBrainRefusalControl(
|
||||
{
|
||||
registrySource: registry(),
|
||||
targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
brainNamespace: 'mosaicstack',
|
||||
refusalIdentity: 'synthetic-no-token',
|
||||
},
|
||||
(request): CommandResult => {
|
||||
requests.push(request);
|
||||
if (request.program === 'mosaic') {
|
||||
return {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 128,
|
||||
stdout: '',
|
||||
stderr: 'credential helper refused reason=no-token-for-identity',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
observed: true,
|
||||
reasonCode: 'no-token-for-identity',
|
||||
gitReasonCode: 'no-token-for-identity',
|
||||
apiReasonCode: 'no-token-for-identity',
|
||||
});
|
||||
expect(requests.map((request) => request.program)).toEqual(['mosaic', 'git']);
|
||||
expect(requests[1]?.args).toEqual([
|
||||
'ls-remote',
|
||||
'https://git.mosaicstack.dev/mosaicstack/mosaic-brain.git',
|
||||
'HEAD',
|
||||
]);
|
||||
expect(
|
||||
requests.every((request) => request.env['MOSAIC_GIT_IDENTITY'] === 'synthetic-no-token'),
|
||||
).toBe(true);
|
||||
expect(requests.some(requestHasSecretShape)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails when the API refuses but Git transport accepts the out-of-estate identity', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-05 production axis disagreement');
|
||||
|
||||
const result = runtime.collectBrainRefusalControl(
|
||||
{
|
||||
registrySource: registry(),
|
||||
targetGitUrl: 'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
brainNamespace: 'mosaicstack',
|
||||
refusalIdentity: 'synthetic-no-token',
|
||||
},
|
||||
(request): CommandResult =>
|
||||
request.program === 'mosaic'
|
||||
? {
|
||||
status: 10,
|
||||
stdout: validateResult('refused', 'no-token-for-identity'),
|
||||
stderr: 'refused reason=no-token-for-identity',
|
||||
}
|
||||
: { status: 0, stdout: 'refs are visible', stderr: '' },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
observed: false,
|
||||
reasonCode: 'permission-evidence-disagrees',
|
||||
gitReasonCode: 'transport-accepted',
|
||||
apiReasonCode: 'no-token-for-identity',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('push-on-write publication', (): void => {
|
||||
it('commits with command-scoped identity, pushes immediately, and proves reachability from origin/main', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-07 publish-on-write reachability');
|
||||
const root = tempRoot();
|
||||
const requests: CommandRequest[] = [];
|
||||
const commit = 'a'.repeat(40);
|
||||
const remoteHead = 'b'.repeat(40);
|
||||
const runner: CommandRunner = (request): CommandResult => {
|
||||
requests.push(request);
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('rev-parse HEAD'))
|
||||
return { status: 0, stdout: `${commit}\n`, stderr: '' };
|
||||
if (command.includes('rev-parse origin/main')) {
|
||||
return { status: 0, stdout: `${remoteHead}\n`, stderr: '' };
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
|
||||
const evidence = runtime.publishBrainPaths(
|
||||
{
|
||||
root,
|
||||
identity: 'seat-a',
|
||||
paths: [join(root, '.gitignore'), join(root, 'lanes', 'lane-a', '.gitkeep')],
|
||||
message: 'migrate lane-a state',
|
||||
},
|
||||
runner,
|
||||
);
|
||||
|
||||
expect(evidence).toEqual({ commit, remoteHead, reachable: true });
|
||||
const rendered = requests.map((request) => `${request.program} ${request.args.join(' ')}`);
|
||||
expect(rendered).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/git -C .* add -- \.gitignore lanes\/lane-a\/\.gitkeep/),
|
||||
expect.stringMatching(
|
||||
/git -C .* -c user\.name=seat-a -c user\.email=seat-a@fleet\.mosaicstack\.dev commit/,
|
||||
),
|
||||
expect.stringMatching(/git -C .* push origin HEAD:main/),
|
||||
expect.stringMatching(/git -C .* fetch origin main/),
|
||||
expect.stringMatching(/git -C .* merge-base --is-ancestor/),
|
||||
]),
|
||||
);
|
||||
const pushIndex = rendered.findIndex((command) => command.includes(' push origin HEAD:main'));
|
||||
const fetchIndex = rendered.findIndex((command) => command.includes(' fetch origin main'));
|
||||
expect(pushIndex).toBeGreaterThan(-1);
|
||||
expect(fetchIndex).toBeGreaterThan(pushIndex);
|
||||
expect(rendered.join('\n')).not.toMatch(/timer|cron|interval/);
|
||||
expect(requests.some(requestHasSecretShape)).toBe(false);
|
||||
});
|
||||
|
||||
it('rebases and retries a rejected concurrent append-only push instead of choosing last-writer-wins', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-07 append-only multi-host conflict');
|
||||
const root = tempRoot();
|
||||
const requests: CommandRequest[] = [];
|
||||
const firstCommit = 'a'.repeat(40);
|
||||
const rebasedCommit = 'c'.repeat(40);
|
||||
const remoteHead = 'd'.repeat(40);
|
||||
let pushes = 0;
|
||||
let rebased = false;
|
||||
|
||||
const evidence = runtime.publishBrainPaths(
|
||||
{
|
||||
root,
|
||||
identity: 'seat-a',
|
||||
paths: [join(root, 'lanes', 'lane-a', 'findings', 'host-a.md')],
|
||||
message: 'append host-a finding',
|
||||
},
|
||||
(request): CommandResult => {
|
||||
requests.push(request);
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('push origin HEAD:main')) {
|
||||
pushes += 1;
|
||||
return pushes === 1
|
||||
? { status: 1, stdout: '', stderr: 'non-fast-forward' }
|
||||
: { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rebase origin/main')) {
|
||||
rebased = true;
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse HEAD')) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: `${rebased ? rebasedCommit : firstCommit}\n`,
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
if (command.includes('rev-parse origin/main')) {
|
||||
return { status: 0, stdout: `${remoteHead}\n`, stderr: '' };
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
);
|
||||
|
||||
const rendered = requests.map((request) => request.args.join(' '));
|
||||
const firstPush = rendered.findIndex((command) => command.includes('push origin HEAD:main'));
|
||||
const rebase = rendered.findIndex((command) => command.includes('rebase origin/main'));
|
||||
const secondPush = rendered
|
||||
.map((command): boolean => command.includes('push origin HEAD:main'))
|
||||
.lastIndexOf(true);
|
||||
expect(firstPush).toBeGreaterThan(-1);
|
||||
expect(rebase).toBeGreaterThan(firstPush);
|
||||
expect(secondPush).toBeGreaterThan(rebase);
|
||||
expect(pushes).toBe(2);
|
||||
expect(evidence).toEqual({ commit: rebasedCommit, remoteHead, reachable: true });
|
||||
});
|
||||
|
||||
it('re-proves reachability without inventing a commit when content was already published', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-07 idempotent publication read-back');
|
||||
const root = tempRoot();
|
||||
const commit = 'e'.repeat(40);
|
||||
const requests: CommandRequest[] = [];
|
||||
|
||||
const evidence = runtime.publishBrainPaths(
|
||||
{
|
||||
root,
|
||||
identity: 'seat-a',
|
||||
paths: [join(root, 'lanes', 'lane-a', 'already-present.md')],
|
||||
message: 'append existing finding',
|
||||
},
|
||||
(request): CommandResult => {
|
||||
requests.push(request);
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes(' commit ')) {
|
||||
return { status: 1, stdout: '', stderr: 'nothing to commit' };
|
||||
}
|
||||
if (command.includes('diff --cached --quiet')) {
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse HEAD') || command.includes('rev-parse origin/main')) {
|
||||
return { status: 0, stdout: `${commit}\n`, stderr: '' };
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
);
|
||||
|
||||
expect(evidence).toEqual({ commit, remoteHead: commit, reachable: true });
|
||||
expect(requests.some((request) => request.args.includes('push'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not manufacture reachability when merge-base rejects the new commit', async (): Promise<void> => {
|
||||
const runtime = await loadRuntime('MB-REQ-07 publication reachability negative control');
|
||||
const root = tempRoot();
|
||||
const commit = 'a'.repeat(40);
|
||||
const remoteHead = 'b'.repeat(40);
|
||||
|
||||
const evidence = runtime.publishBrainPaths(
|
||||
{
|
||||
root,
|
||||
identity: 'seat-a',
|
||||
paths: [join(root, '.gitignore')],
|
||||
message: 'seed brain',
|
||||
},
|
||||
(request): CommandResult => {
|
||||
const command = request.args.join(' ');
|
||||
if (command.includes('rev-parse HEAD')) {
|
||||
return { status: 0, stdout: `${commit}\n`, stderr: '' };
|
||||
}
|
||||
if (command.includes('rev-parse origin/main')) {
|
||||
return { status: 0, stdout: `${remoteHead}\n`, stderr: '' };
|
||||
}
|
||||
if (command.includes('merge-base --is-ancestor')) {
|
||||
return { status: 1, stdout: '', stderr: '' };
|
||||
}
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
);
|
||||
|
||||
expect(evidence).toEqual({ commit, remoteHead, reachable: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isAbsolute, 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}$/;
|
||||
|
||||
export interface CommandRequest {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly cwd?: string;
|
||||
readonly env: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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) });
|
||||
}
|
||||
|
||||
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 dirty: boolean | null = null;
|
||||
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) dirty = statusResult.stdout.trim().length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
const observation: BrainDoctorObservation = {
|
||||
rootExists,
|
||||
gitRepository,
|
||||
remote,
|
||||
branch,
|
||||
dirty,
|
||||
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 paths: readonly string[];
|
||||
readonly message: string;
|
||||
},
|
||||
run: CommandRunner,
|
||||
): PublishEvidence {
|
||||
if (input.paths.length === 0) throw new Error('brain-publish-paths-empty');
|
||||
if (input.message.trim().length === 0) throw new Error('brain-publish-message-empty');
|
||||
const paths = input.paths.map((path: string): string => containedRelative(input.root, path));
|
||||
|
||||
requireSuccess(
|
||||
runGit(run, input.identity, ['-C', input.root, 'add', '--', ...paths]),
|
||||
'brain-git-add',
|
||||
);
|
||||
const commitAttempt = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'-c',
|
||||
`user.name=${input.identity}`,
|
||||
'-c',
|
||||
`user.email=${input.identity}@fleet.mosaicstack.dev`,
|
||||
'commit',
|
||||
'-m',
|
||||
input.message,
|
||||
]);
|
||||
if (commitAttempt.status !== 0) {
|
||||
const stagedDifference = runGit(run, input.identity, [
|
||||
'-C',
|
||||
input.root,
|
||||
'diff',
|
||||
'--cached',
|
||||
'--quiet',
|
||||
'--exit-code',
|
||||
]);
|
||||
if (stagedDifference.status !== 0) throw new Error('brain-git-commit-failed');
|
||||
}
|
||||
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;
|
||||
};
|
||||
let commit = readHead();
|
||||
let pushed = false;
|
||||
for (let attempt = 0; 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, 'rebase', 'origin/main']),
|
||||
'brain-git-rebase-concurrent',
|
||||
);
|
||||
commit = readHead();
|
||||
}
|
||||
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 };
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -7,7 +16,7 @@ import { join } from 'node:path';
|
||||
* Red-first contract checks for stack #1051 / MB-BRAIN-01.
|
||||
*
|
||||
* These checks are committed before the implementation. They bind to the
|
||||
* MC-CRED v1.3 terminal classes and stable reason codes, not to the currently
|
||||
* MC-CRED v1.5 terminal classes and stable reason codes, not to the currently
|
||||
* deployed resolver behavior. Live grant and read/write round-trip tests remain
|
||||
* gated on MC-CRED-01; these fixtures contain no credential values.
|
||||
*/
|
||||
@@ -32,10 +41,23 @@ interface ResolverParityAssessment extends CredentialAssessment {
|
||||
readonly apiReasonCode: string;
|
||||
}
|
||||
|
||||
interface MigrationOwner {
|
||||
readonly name: string;
|
||||
readonly kind: 'active-lane' | 'durable-team' | 'durable-human' | 'durable-queue';
|
||||
readonly validated: boolean;
|
||||
interface MigrationOwnerResolution {
|
||||
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||||
readonly reasonCode: string;
|
||||
readonly principal: {
|
||||
readonly name: string;
|
||||
readonly kind:
|
||||
| 'active-lane'
|
||||
| 'durable-team'
|
||||
| 'durable-human'
|
||||
| 'durable-queue'
|
||||
| 'mission-seat';
|
||||
} | null;
|
||||
readonly authority: {
|
||||
readonly system: 'gitea' | 'glpi' | 'mosaic-mission-state';
|
||||
readonly endpoint: string;
|
||||
readonly contentType: 'application/json';
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface MigrationCandidate {
|
||||
@@ -49,7 +71,7 @@ interface MigrationPlan {
|
||||
readonly status: 'ready' | 'blocked';
|
||||
readonly candidates: readonly MigrationCandidate[];
|
||||
readonly reported: readonly { path: string; reason: string }[];
|
||||
readonly owner: MigrationOwner | null;
|
||||
readonly owner: MigrationOwnerResolution['principal'];
|
||||
}
|
||||
|
||||
interface MigrationPublishEvidence {
|
||||
@@ -93,18 +115,24 @@ interface BrainWritePolicy {
|
||||
}
|
||||
|
||||
interface BrainStoreModule {
|
||||
deriveBrainTarget(registrySource: string, targetGitUrl: string): BrainTarget;
|
||||
deriveBrainTarget(
|
||||
registrySource: string,
|
||||
targetGitUrl: string,
|
||||
brainNamespace: string,
|
||||
): BrainTarget;
|
||||
createBrainSkeleton(root: string): { readonly created: readonly string[] };
|
||||
assessCredentialResult(source: string): CredentialAssessment;
|
||||
assessResolverParity(gitSource: string, apiSource: string): ResolverParityAssessment;
|
||||
discoverBrainMigration(input: {
|
||||
readonly sourceRoot: string;
|
||||
readonly brainRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
readonly owner?: MigrationOwner;
|
||||
}): MigrationPlan;
|
||||
discoverBrainMigration(
|
||||
input: {
|
||||
readonly sourceRoot: string;
|
||||
readonly brainRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
},
|
||||
resolveOwner?: (lane: string) => MigrationOwnerResolution,
|
||||
): MigrationPlan;
|
||||
migrateBrainState(
|
||||
plan: MigrationPlan,
|
||||
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
||||
@@ -182,6 +210,11 @@ function credentialResult(
|
||||
outcome: CredentialAssessment['outcome'],
|
||||
reasonCode: string,
|
||||
message = 'non-secret diagnostic',
|
||||
providerLogin: string | null = outcome === 'ok'
|
||||
? 'external-seat'
|
||||
: reasonCode === 'provider-identity-mismatch'
|
||||
? 'Mos'
|
||||
: null,
|
||||
): string {
|
||||
const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const;
|
||||
return JSON.stringify({
|
||||
@@ -199,9 +232,41 @@ function credentialResult(
|
||||
mutation: 'none',
|
||||
reason: { code: reasonCode, message },
|
||||
evidence: {
|
||||
providerIdentity: null,
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
providerIdentity:
|
||||
providerLogin === null
|
||||
? null
|
||||
: {
|
||||
login: providerLogin,
|
||||
endpoint: 'GET /api/v1/user',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
repositoryPermission:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
requested: 'write',
|
||||
effective: 'write',
|
||||
endpoint: 'GET /api/v1/repos/mosaicstack/mosaic-brain',
|
||||
contentType: 'application/json',
|
||||
}
|
||||
: null,
|
||||
writeDifferential:
|
||||
outcome === 'ok'
|
||||
? {
|
||||
state: 'can-write',
|
||||
credentialBinding: 'same-resolution',
|
||||
transportPrincipal: 'external-seat',
|
||||
authenticatedReceivePack: 'advertised',
|
||||
readOnlyControl: {
|
||||
identity: 'homelab-read-control',
|
||||
providerPermission: 'read',
|
||||
receivePack: 'refused',
|
||||
},
|
||||
unauthenticatedReceivePack: 'refused',
|
||||
artifactCreated: false,
|
||||
proves: 'non-secret evidence',
|
||||
doesNotProve: 'branch update acceptance',
|
||||
}
|
||||
: null,
|
||||
},
|
||||
audit: { journalId: 'opaque', state: 'sealed' },
|
||||
});
|
||||
@@ -218,13 +283,17 @@ describe('R2/Q1 — estate and brain discovery have one authority', (): void =>
|
||||
const sut = await loadSut('MB-REQ-02 target-host estate derivation');
|
||||
|
||||
expect(
|
||||
sut.deriveBrainTarget(registry(), 'https://git.mosaicstack.dev/mosaicstack/stack.git'),
|
||||
sut.deriveBrainTarget(
|
||||
registry(),
|
||||
'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
'durable-owner',
|
||||
),
|
||||
).toEqual({
|
||||
estate: 'homelab',
|
||||
host: 'git.mosaicstack.dev',
|
||||
owner: 'mosaicstack',
|
||||
repo: 'mosaicstack/mosaic-brain',
|
||||
cloneUrl: 'https://git.mosaicstack.dev/mosaicstack/mosaic-brain.git',
|
||||
owner: 'durable-owner',
|
||||
repo: 'durable-owner/mosaic-brain',
|
||||
cloneUrl: 'https://git.mosaicstack.dev/durable-owner/mosaic-brain.git',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,7 +303,11 @@ describe('R2/Q1 — estate and brain discovery have one authority', (): void =>
|
||||
process.env['MOSAIC_ESTATE'] = 'homelab';
|
||||
try {
|
||||
expect(() =>
|
||||
sut.deriveBrainTarget(registry(), 'https://unmapped.example.invalid/acme/stack.git'),
|
||||
sut.deriveBrainTarget(
|
||||
registry(),
|
||||
'https://unmapped.example.invalid/acme/stack.git',
|
||||
'durable-owner',
|
||||
),
|
||||
).toThrow(/estate-host-unmapped/);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['MOSAIC_ESTATE'];
|
||||
@@ -252,11 +325,41 @@ describe('R6 — brain layout refuses secret material', (): void => {
|
||||
|
||||
for (const directory of ['agents', 'lanes', 'board', 'specs', 'methods', 'archives']) {
|
||||
expect(existsSync(join(root, directory)), directory).toBe(true);
|
||||
expect(
|
||||
existsSync(join(root, directory, '.gitkeep')),
|
||||
`${directory} tracked placeholder`,
|
||||
).toBe(true);
|
||||
}
|
||||
const rules = readFileSync(join(root, '.gitignore'), 'utf8').trim().split('\n');
|
||||
expect(rules).toEqual(['*.token', '*.key', '*.pem', '.env', 'credentials.json']);
|
||||
});
|
||||
|
||||
it('refuses a symlinked layout directory without writing a tracked placeholder outside the brain', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-06 no-follow brain layout');
|
||||
const root = tempRoot();
|
||||
const brain = join(root, 'brain');
|
||||
const outside = join(root, 'outside');
|
||||
mkdirSync(brain);
|
||||
mkdirSync(outside);
|
||||
symlinkSync(outside, join(brain, 'agents'));
|
||||
|
||||
expect(() => sut.createBrainSkeleton(brain)).toThrow(/brain-layout-directory-unsafe/);
|
||||
expect(existsSync(join(outside, '.gitkeep'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a symlinked gitignore instead of reading and tracking its external target', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-06 no-follow gitignore');
|
||||
const root = tempRoot();
|
||||
const brain = join(root, 'brain');
|
||||
const outside = join(root, 'outside-secret');
|
||||
mkdirSync(brain);
|
||||
writeFileSync(outside, 'DO-NOT-TRACK\n');
|
||||
symlinkSync(outside, join(brain, '.gitignore'));
|
||||
|
||||
expect(() => sut.createBrainSkeleton(brain)).toThrow(/brain-layout-ignore-unsafe/);
|
||||
expect(readFileSync(outside, 'utf8')).toBe('DO-NOT-TRACK\n');
|
||||
});
|
||||
|
||||
it('never relays broker reason messages that may contain secret-bearing text', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-06 secret-free error path');
|
||||
const secretMarker = 'DO-NOT-EMIT-CREDENTIAL-MARKER';
|
||||
@@ -270,14 +373,16 @@ describe('R6 — brain layout refuses secret material', (): void => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential caller contract v1.3 terminal classes', (): void => {
|
||||
describe('credential caller contract v1.5 terminal classes', (): void => {
|
||||
it.each([
|
||||
['ok', 0, 'grant-verified'],
|
||||
['ok', 0, 'validation-verified'],
|
||||
['refused', 10, 'no-token-for-identity'],
|
||||
['refused', 10, 'provider-identity-mismatch'],
|
||||
['error', 20, 'estate-registry-invalid'],
|
||||
['indeterminate', 30, 'provider-unavailable'],
|
||||
['indeterminate', 30, 'identity-not-found'],
|
||||
['indeterminate', 30, 'credential-rejected'],
|
||||
['indeterminate', 30, 'identity-not-visible'],
|
||||
['indeterminate', 30, 'identity-not-measured'],
|
||||
['refused', 10, 'credential-rejected'],
|
||||
] as const)(
|
||||
'preserves %s/%i and stable reason %s without parsing prose',
|
||||
async (outcome, exitCode, reasonCode): Promise<void> => {
|
||||
@@ -293,7 +398,7 @@ describe('credential caller contract v1.3 terminal classes', (): void => {
|
||||
|
||||
it('makes a missing or inconsistent decision field indeterminate rather than success or refusal', async (): Promise<void> => {
|
||||
const sut = await loadSut('AC-MB-01 malformed broker result fail-closed');
|
||||
const malformed = JSON.parse(credentialResult('ok', 'grant-verified')) as Record<
|
||||
const malformed = JSON.parse(credentialResult('ok', 'validation-verified')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
@@ -305,6 +410,66 @@ describe('credential caller contract v1.3 terminal classes', (): void => {
|
||||
expect(result.exitCode).toBe(30);
|
||||
expect(result.reasonCode).toBe('unexpected-provider-shape');
|
||||
});
|
||||
|
||||
it('makes an apparent write ok indeterminate when the side-effect-free differential is absent', async (): Promise<void> => {
|
||||
const sut = await loadSut('AC-MB-01 required write differential');
|
||||
const raw = JSON.parse(credentialResult('ok', 'validation-verified')) as {
|
||||
evidence: { writeDifferential: unknown };
|
||||
};
|
||||
raw.evidence.writeDifferential = null;
|
||||
|
||||
const result = sut.assessCredentialResult(JSON.stringify(raw));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
exitCode: 30,
|
||||
reasonCode: 'readback-missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the superseded indeterminate/credential-rejected pairing from before the v1.4 correction', async (): Promise<void> => {
|
||||
const sut = await loadSut('AC-MB-01 credential-rejected stable class');
|
||||
|
||||
const legacy = JSON.parse(credentialResult('indeterminate', 'provider-unavailable')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
legacy['reason'] = {
|
||||
code: 'credential-rejected',
|
||||
message: 'superseded classification',
|
||||
};
|
||||
const result = sut.assessCredentialResult(JSON.stringify(legacy));
|
||||
|
||||
expect(result.outcome).toBe('indeterminate');
|
||||
expect(result.exitCode).toBe(30);
|
||||
expect(result.reasonCode).toBe('unexpected-provider-shape');
|
||||
});
|
||||
|
||||
it('preserves scope-limited identity as not-measured rather than a dead credential refusal', async (): Promise<void> => {
|
||||
const sut = await loadSut('AC-MB-01 scope-limited identity is not dead credential');
|
||||
|
||||
const result = sut.assessCredentialResult(
|
||||
credentialResult('indeterminate', 'identity-not-measured'),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
outcome: 'indeterminate',
|
||||
exitCode: 30,
|
||||
reasonCode: 'identity-not-measured',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an apparent ok whose provider /user read-back names a different principal', async (): Promise<void> => {
|
||||
const sut = await loadSut('AC-MB-01 provider identity MISMATCH is first-class');
|
||||
|
||||
const result = sut.assessCredentialResult(
|
||||
credentialResult('ok', 'validation-verified', 'looks successful', 'Mos'),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe('refused');
|
||||
expect(result.exitCode).toBe(10);
|
||||
expect(result.reasonCode).toBe('provider-identity-mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('R5 — out-of-estate refusal must agree on both resolver axes', (): void => {
|
||||
@@ -324,7 +489,7 @@ describe('R5 — out-of-estate refusal must agree on both resolver axes', (): vo
|
||||
it.each([
|
||||
[
|
||||
credentialResult('refused', 'no-token-for-identity'),
|
||||
credentialResult('ok', 'grant-verified'),
|
||||
credentialResult('ok', 'validation-verified'),
|
||||
],
|
||||
[
|
||||
credentialResult('refused', 'no-token-for-identity'),
|
||||
@@ -346,8 +511,19 @@ describe('R5 — out-of-estate refusal must agree on both resolver axes', (): vo
|
||||
});
|
||||
|
||||
describe('R7 — migration is non-destructive, append-only, and explicit', (): void => {
|
||||
it('detects canonical lane and current-seat state while explicitly reporting unsupported local state', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-07 migration detection/reporting');
|
||||
const activeLaneOwner = (): MigrationOwnerResolution => ({
|
||||
verdict: 'resolved',
|
||||
reasonCode: 'active-lane-owner-verified',
|
||||
principal: { name: 'lane:lane-a', kind: 'active-lane' },
|
||||
authority: {
|
||||
system: 'mosaic-mission-state',
|
||||
endpoint: 'file:///var/lib/mosaic/missions/lane-a.json',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
it('uses an injected authoritative owner resolver for unit mechanics without claiming live owner validation', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-07 injected owner-resolver seam');
|
||||
const root = tempRoot();
|
||||
const sourceRoot = join(root, 'local-memory');
|
||||
const brainRoot = join(root, 'brain');
|
||||
@@ -357,16 +533,13 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
writeFileSync(join(sourceRoot, 'agents', 'seat-a', 'STATE.md'), 'seat state\n');
|
||||
writeFileSync(join(sourceRoot, 'orphan-state.md'), 'must be reported\n');
|
||||
|
||||
const plan = sut.discoverBrainMigration({
|
||||
sourceRoot,
|
||||
brainRoot,
|
||||
seat: 'seat-a',
|
||||
lane: 'lane-a',
|
||||
laneActive: true,
|
||||
owner: { name: 'lane-a', kind: 'active-lane', validated: true },
|
||||
});
|
||||
const plan = sut.discoverBrainMigration(
|
||||
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
|
||||
activeLaneOwner,
|
||||
);
|
||||
|
||||
expect(plan.status).toBe('ready');
|
||||
expect(plan.owner).toEqual({ name: 'lane:lane-a', kind: 'active-lane' });
|
||||
expect(plan.candidates.map((candidate) => candidate.kind).sort()).toEqual(['lane', 'seat']);
|
||||
expect(plan.reported).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -375,20 +548,22 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the migration gate blocking when no validated owner exists', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-09 named durable owner gate');
|
||||
it('ignores a caller-asserted owner string/validated flag and leaves the gate blocking', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-09 caller owner assertion cannot satisfy gate');
|
||||
const root = tempRoot();
|
||||
const sourceRoot = join(root, 'local-memory');
|
||||
mkdirSync(join(sourceRoot, 'lanes', 'closed-lane'), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, 'lanes', 'closed-lane', 'finding.md'), 'finding\n');
|
||||
|
||||
const plan = sut.discoverBrainMigration({
|
||||
const callerAssertion = {
|
||||
sourceRoot,
|
||||
brainRoot: join(root, 'brain'),
|
||||
seat: 'seat-a',
|
||||
lane: 'closed-lane',
|
||||
laneActive: false,
|
||||
});
|
||||
owner: { name: 'some-string', kind: 'durable-team', validated: true },
|
||||
};
|
||||
|
||||
const plan = sut.discoverBrainMigration(callerAssertion);
|
||||
|
||||
expect(plan.status).toBe('blocked');
|
||||
expect(plan.candidates).toHaveLength(0);
|
||||
@@ -399,6 +574,74 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mission seat', { name: 'user:be-coder-07', kind: 'mission-seat' }],
|
||||
['unicode dash', { name: 'team:platform–maintainers', kind: 'durable-team' }],
|
||||
['padded', { name: ' team:platform-maintainers ', kind: 'durable-team' }],
|
||||
['dot presentation', { name: 'team:platform.maintainers', kind: 'durable-team' }],
|
||||
['space presentation', { name: 'team:platform maintainers', kind: 'durable-team' }],
|
||||
] as const)(
|
||||
'rejects %s owner evidence through the injected resolver allowlist',
|
||||
async (_caseName, principal): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-09 durable-owner allowlist');
|
||||
const root = tempRoot();
|
||||
const sourceRoot = join(root, 'local-memory');
|
||||
mkdirSync(join(sourceRoot, 'lanes', 'closed-lane'), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, 'lanes', 'closed-lane', 'finding.md'), 'finding\n');
|
||||
|
||||
let resolverCalls = 0;
|
||||
const plan = sut.discoverBrainMigration(
|
||||
{
|
||||
sourceRoot,
|
||||
brainRoot: join(root, 'brain'),
|
||||
seat: 'seat-a',
|
||||
lane: 'closed-lane',
|
||||
laneActive: false,
|
||||
},
|
||||
(): MigrationOwnerResolution => {
|
||||
resolverCalls += 1;
|
||||
return {
|
||||
verdict: 'resolved',
|
||||
reasonCode: 'owner-verified',
|
||||
principal,
|
||||
authority: {
|
||||
system: 'gitea',
|
||||
endpoint: 'GET /api/v1/teams/1',
|
||||
contentType: 'application/json',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolverCalls).toBe(1);
|
||||
expect(plan.status).toBe('blocked');
|
||||
expect(plan.candidates).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('reports secret-shaped legacy files without ever copying them into the brain', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-06 migration secret exclusion');
|
||||
const root = tempRoot();
|
||||
const sourceRoot = join(root, 'local-memory');
|
||||
const laneRoot = join(sourceRoot, 'lanes', 'lane-a');
|
||||
const brainRoot = join(root, 'brain');
|
||||
mkdirSync(laneRoot, { recursive: true });
|
||||
for (const name of ['access.token', 'private.key', 'client.pem', '.env', 'credentials.json']) {
|
||||
writeFileSync(join(laneRoot, name), 'DO-NOT-MIGRATE\n');
|
||||
}
|
||||
|
||||
const plan = sut.discoverBrainMigration(
|
||||
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
|
||||
activeLaneOwner,
|
||||
);
|
||||
|
||||
expect(plan.status).toBe('ready');
|
||||
expect(plan.candidates).toHaveLength(0);
|
||||
expect(plan.reported).toHaveLength(5);
|
||||
expect(plan.reported.every((entry) => /secret/i.test(entry.reason))).toBe(true);
|
||||
expect(existsSync(brainRoot)).toBe(false);
|
||||
});
|
||||
|
||||
it('publishes collision-safe append-only copies before archiving sources and never overwrites a finding', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-07 append-only publish-before-archive migration');
|
||||
const root = tempRoot();
|
||||
@@ -406,14 +649,10 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
const brainRoot = join(root, 'brain');
|
||||
mkdirSync(join(sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
writeFileSync(join(sourceRoot, 'lanes', 'lane-a', 'finding.md'), 'new finding\n');
|
||||
const plan = sut.discoverBrainMigration({
|
||||
sourceRoot,
|
||||
brainRoot,
|
||||
seat: 'seat-a',
|
||||
lane: 'lane-a',
|
||||
laneActive: true,
|
||||
owner: { name: 'lane-a', kind: 'active-lane', validated: true },
|
||||
});
|
||||
const plan = sut.discoverBrainMigration(
|
||||
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
|
||||
activeLaneOwner,
|
||||
);
|
||||
const candidate = plan.candidates[0];
|
||||
expect(candidate).toBeDefined();
|
||||
if (candidate === undefined) return;
|
||||
@@ -447,6 +686,39 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
expect(publishedPaths).toContain(candidate.archive);
|
||||
});
|
||||
|
||||
it('refuses nested symlink destinations without copying a migration outside the brain', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-07 migration destination no-follow');
|
||||
const root = tempRoot();
|
||||
const sourceRoot = join(root, 'local-memory');
|
||||
const brainRoot = join(root, 'brain');
|
||||
const outside = join(root, 'outside');
|
||||
mkdirSync(join(sourceRoot, 'lanes', 'lane-a'), { recursive: true });
|
||||
mkdirSync(join(brainRoot, 'lanes'), { recursive: true });
|
||||
mkdirSync(outside);
|
||||
const source = join(sourceRoot, 'lanes', 'lane-a', 'finding.md');
|
||||
writeFileSync(source, 'lane state\n');
|
||||
symlinkSync(outside, join(brainRoot, 'lanes', 'lane-a'));
|
||||
const plan = sut.discoverBrainMigration(
|
||||
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
|
||||
activeLaneOwner,
|
||||
);
|
||||
let publishCalls = 0;
|
||||
|
||||
const result = sut.migrateBrainState(
|
||||
plan,
|
||||
(): MigrationPublishEvidence => {
|
||||
publishCalls += 1;
|
||||
return { commit: 'a'.repeat(40), remoteHead: 'a'.repeat(40), reachable: true };
|
||||
},
|
||||
brainRoot,
|
||||
);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(readFileSync(source, 'utf8')).toBe('lane state\n');
|
||||
expect(readdirSync(outside)).toEqual([]);
|
||||
expect(publishCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('retains every source and reports failure when remote reachability is not established', async (): Promise<void> => {
|
||||
const sut = await loadSut('MB-REQ-07 failed-publish source preservation');
|
||||
const root = tempRoot();
|
||||
@@ -455,14 +727,10 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
mkdirSync(join(sourceRoot, 'agents', 'seat-a'), { recursive: true });
|
||||
const source = join(sourceRoot, 'agents', 'seat-a', 'STATE.md');
|
||||
writeFileSync(source, 'seat state\n');
|
||||
const plan = sut.discoverBrainMigration({
|
||||
sourceRoot,
|
||||
brainRoot,
|
||||
seat: 'seat-a',
|
||||
lane: 'lane-a',
|
||||
laneActive: true,
|
||||
owner: { name: 'lane-a', kind: 'active-lane', validated: true },
|
||||
});
|
||||
const plan = sut.discoverBrainMigration(
|
||||
{ sourceRoot, brainRoot, seat: 'seat-a', lane: 'lane-a', laneActive: true },
|
||||
activeLaneOwner,
|
||||
);
|
||||
|
||||
const result = sut.migrateBrainState(
|
||||
plan,
|
||||
@@ -476,6 +744,9 @@ describe('R7 — migration is non-destructive, append-only, and explicit', (): v
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(readFileSync(source, 'utf8')).toBe('seat state\n');
|
||||
expect(plan.candidates[0]).toBeDefined();
|
||||
expect(existsSync(plan.candidates[0]!.destination)).toBe(true);
|
||||
expect(existsSync(plan.candidates[0]!.archive)).toBe(true);
|
||||
expect(result.reported).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ reason: expect.stringMatching(/reachab/i) }),
|
||||
@@ -496,11 +767,13 @@ describe('R8 — doctor diagnoses defects and fixes only through approved seams'
|
||||
remote: null,
|
||||
branch: null,
|
||||
dirty: null,
|
||||
access: null,
|
||||
access: sut.assessCredentialResult(credentialResult('refused', 'no-token-for-identity')),
|
||||
},
|
||||
expected,
|
||||
);
|
||||
expect(missing.map((finding) => finding.code)).toContain('brain-clone-missing');
|
||||
expect(missing.map((finding) => finding.code)).toEqual(
|
||||
expect.arrayContaining(['brain-clone-missing', 'brain-write-access-refused']),
|
||||
);
|
||||
|
||||
const defects = sut.evaluateBrainDoctor(
|
||||
{
|
||||
@@ -529,6 +802,7 @@ describe('R8 — doctor diagnoses defects and fixes only through approved seams'
|
||||
const target = sut.deriveBrainTarget(
|
||||
registry(),
|
||||
'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
'durable-owner',
|
||||
);
|
||||
const findings: BrainDoctorFinding[] = [
|
||||
{ code: 'brain-clone-missing', repairable: true, reasonCode: null },
|
||||
@@ -545,7 +819,9 @@ describe('R8 — doctor diagnoses defects and fixes only through approved seams'
|
||||
});
|
||||
const rendered = JSON.stringify(actions);
|
||||
|
||||
expect(actions.map((action) => action.program)).toEqual(['git', 'git', 'mosaic']);
|
||||
expect(actions.map((action) => action.program)).toEqual(['mosaic', 'git', 'git']);
|
||||
expect(actions[0]?.findingCode).toBe('brain-write-access-refused');
|
||||
expect(actions[1]?.findingCode).toBe('brain-clone-missing');
|
||||
expect(rendered).toContain('cred');
|
||||
expect(rendered).toContain('grant');
|
||||
expect(rendered).toContain('--estate');
|
||||
@@ -559,6 +835,7 @@ describe('R8 — doctor diagnoses defects and fixes only through approved seams'
|
||||
const target = sut.deriveBrainTarget(
|
||||
registry(),
|
||||
'https://git.mosaicstack.dev/mosaicstack/stack.git',
|
||||
'durable-owner',
|
||||
);
|
||||
|
||||
for (const finding of [
|
||||
|
||||
@@ -0,0 +1,955 @@
|
||||
import {
|
||||
closeSync,
|
||||
constants as fsConstants,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
lstatSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||
|
||||
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
||||
const COMMIT = /^[0-9a-f]{40}$/;
|
||||
const GITIGNORE_RULES = ['*.token', '*.key', '*.pem', '.env', 'credentials.json'] as const;
|
||||
const BRAIN_DIRECTORIES = ['agents', 'lanes', 'board', 'specs', 'methods', 'archives'] as const;
|
||||
const TERMINAL_EXITS = {
|
||||
ok: 0,
|
||||
refused: 10,
|
||||
error: 20,
|
||||
indeterminate: 30,
|
||||
} as const;
|
||||
const STABLE_REASON_CLASSES: Readonly<Record<string, CredentialOutcome>> = {
|
||||
'identity-required': 'refused',
|
||||
'estate-required': 'refused',
|
||||
'estate-host-mismatch': 'refused',
|
||||
'cross-estate-resolution': 'refused',
|
||||
'no-token-for-identity': 'refused',
|
||||
'tea-login-missing': 'refused',
|
||||
'tea-login-host-mismatch': 'refused',
|
||||
'provider-identity-mismatch': 'refused',
|
||||
'credential-rejected': 'refused',
|
||||
'permission-denied': 'refused',
|
||||
'organization-membership-required': 'refused',
|
||||
'team-membership-required': 'refused',
|
||||
'invalid-input': 'error',
|
||||
'estate-registry-invalid': 'error',
|
||||
'insecure-credential-source': 'error',
|
||||
'journal-unavailable': 'error',
|
||||
'internal-invariant': 'error',
|
||||
'provider-unavailable': 'indeterminate',
|
||||
'identity-not-visible': 'indeterminate',
|
||||
'identity-not-measured': 'indeterminate',
|
||||
'identity-not-found': 'indeterminate',
|
||||
'unexpected-content-type': 'indeterminate',
|
||||
'unexpected-provider-shape': 'indeterminate',
|
||||
'scope-not-evaluable': 'indeterminate',
|
||||
'permission-evidence-disagrees': 'indeterminate',
|
||||
'transport-principal-mismatch': 'indeterminate',
|
||||
'read-only-control-invalid': 'indeterminate',
|
||||
'readback-missing': 'indeterminate',
|
||||
'mutation-state-unknown': 'indeterminate',
|
||||
};
|
||||
|
||||
export interface BrainTarget {
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly owner: string;
|
||||
readonly repo: string;
|
||||
readonly cloneUrl: string;
|
||||
}
|
||||
|
||||
export type CredentialOutcome = keyof typeof TERMINAL_EXITS;
|
||||
|
||||
export interface CredentialAssessment {
|
||||
readonly outcome: CredentialOutcome;
|
||||
readonly exitCode: 0 | 10 | 20 | 30;
|
||||
readonly reasonCode: string;
|
||||
readonly diagnostic: string;
|
||||
}
|
||||
|
||||
export interface ResolverParityAssessment extends CredentialAssessment {
|
||||
readonly gitReasonCode: string;
|
||||
readonly apiReasonCode: string;
|
||||
}
|
||||
|
||||
export interface MigrationOwnerResolution {
|
||||
readonly verdict: 'resolved' | 'refused' | 'not-measured';
|
||||
readonly reasonCode: string;
|
||||
readonly principal: {
|
||||
readonly name: string;
|
||||
readonly kind:
|
||||
| 'active-lane'
|
||||
| 'durable-team'
|
||||
| 'durable-human'
|
||||
| 'durable-queue'
|
||||
| 'mission-seat';
|
||||
} | null;
|
||||
readonly authority: {
|
||||
readonly system: 'gitea' | 'glpi' | 'mosaic-mission-state';
|
||||
readonly endpoint: string;
|
||||
readonly contentType: 'application/json';
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface MigrationCandidate {
|
||||
readonly source: string;
|
||||
readonly destination: string;
|
||||
readonly archive: string;
|
||||
readonly kind: 'lane' | 'seat';
|
||||
}
|
||||
|
||||
export interface MigrationReport {
|
||||
readonly path: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export interface MigrationPlan {
|
||||
readonly status: 'ready' | 'blocked';
|
||||
readonly candidates: readonly MigrationCandidate[];
|
||||
readonly reported: readonly MigrationReport[];
|
||||
readonly owner: MigrationOwnerResolution['principal'];
|
||||
}
|
||||
|
||||
export interface MigrationPublishEvidence {
|
||||
readonly commit: string;
|
||||
readonly remoteHead: string;
|
||||
readonly reachable: boolean;
|
||||
}
|
||||
|
||||
export interface MigrationResult {
|
||||
readonly status: 'migrated' | 'reported' | 'failed';
|
||||
readonly migrated: readonly MigrationCandidate[];
|
||||
readonly reported: readonly MigrationReport[];
|
||||
readonly publish: MigrationPublishEvidence | null;
|
||||
}
|
||||
|
||||
export interface BrainDoctorObservation {
|
||||
readonly rootExists: boolean;
|
||||
readonly gitRepository: boolean;
|
||||
readonly remote: string | null;
|
||||
readonly branch: string | null;
|
||||
readonly dirty: boolean | null;
|
||||
readonly access: CredentialAssessment | null;
|
||||
}
|
||||
|
||||
export interface BrainDoctorFinding {
|
||||
readonly code: string;
|
||||
readonly repairable: boolean;
|
||||
readonly reasonCode: string | null;
|
||||
}
|
||||
|
||||
export interface BrainDoctorAction {
|
||||
readonly program: 'git' | 'mosaic';
|
||||
readonly args: readonly string[];
|
||||
readonly findingCode: string;
|
||||
}
|
||||
|
||||
export interface BrainWritePolicy {
|
||||
readonly allowed: boolean;
|
||||
readonly mode: 'append-only' | 'single-writer' | 'seat-writer' | 'refused';
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
interface ParsedGitTarget {
|
||||
readonly host: string;
|
||||
readonly owner: string;
|
||||
}
|
||||
|
||||
const providerIdentitySchema = z
|
||||
.object({
|
||||
login: z.string().min(1),
|
||||
endpoint: z.literal('GET /api/v1/user'),
|
||||
contentType: z.string().min(1),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const repositoryPermissionSchema = z
|
||||
.object({
|
||||
requested: z.literal('write'),
|
||||
effective: z.enum(['write', 'admin']),
|
||||
endpoint: z.string().min(1),
|
||||
contentType: z.string().min(1),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const writeDifferentialSchema = z
|
||||
.object({
|
||||
state: z.literal('can-write'),
|
||||
credentialBinding: z.literal('same-resolution'),
|
||||
transportPrincipal: z.string().min(1),
|
||||
authenticatedReceivePack: z.literal('advertised'),
|
||||
readOnlyControl: z
|
||||
.object({
|
||||
identity: z.string().min(1),
|
||||
providerPermission: z.literal('read'),
|
||||
receivePack: z.literal('refused'),
|
||||
})
|
||||
.passthrough(),
|
||||
unauthenticatedReceivePack: z.literal('refused'),
|
||||
artifactCreated: z.literal(false),
|
||||
proves: z.string().min(1),
|
||||
doesNotProve: z.string().min(1),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const credentialResultSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
operation: z.literal('validate'),
|
||||
outcome: z.enum(['ok', 'refused', 'error', 'indeterminate']),
|
||||
exitCode: z.number().int(),
|
||||
subject: z
|
||||
.object({
|
||||
identity: z.string().min(1),
|
||||
estate: z.string().min(1),
|
||||
host: z.string().min(1),
|
||||
repo: z.string().min(1),
|
||||
})
|
||||
.strict(),
|
||||
mutation: z.enum(['none', 'not-started', 'applied', 'unknown']),
|
||||
reason: z
|
||||
.object({
|
||||
code: z.string().min(1),
|
||||
message: z.string(),
|
||||
})
|
||||
.passthrough(),
|
||||
evidence: z
|
||||
.object({
|
||||
providerIdentity: providerIdentitySchema.nullable(),
|
||||
repositoryPermission: repositoryPermissionSchema.nullable(),
|
||||
writeDifferential: writeDifferentialSchema.nullable(),
|
||||
})
|
||||
.passthrough(),
|
||||
audit: z
|
||||
.object({
|
||||
journalId: z.string().nullable(),
|
||||
state: z.enum(['not-started', 'open', 'sealed']),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function safeName(value: string, label: string): string {
|
||||
if (!SAFE_NAME.test(value)) {
|
||||
throw new Error(`invalid-${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseGitTarget(targetGitUrl: string): ParsedGitTarget {
|
||||
let host = '';
|
||||
let pathname = '';
|
||||
|
||||
if (/^[^@\s]+@[^:\s]+:.+$/.test(targetGitUrl)) {
|
||||
const separator = targetGitUrl.indexOf(':');
|
||||
const authority = targetGitUrl.slice(0, separator);
|
||||
host = authority.slice(authority.lastIndexOf('@') + 1);
|
||||
pathname = targetGitUrl.slice(separator + 1);
|
||||
} else {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(targetGitUrl);
|
||||
} catch {
|
||||
throw new Error('target-git-url-invalid');
|
||||
}
|
||||
if (!['https:', 'ssh:'].includes(parsed.protocol) || parsed.password !== '') {
|
||||
throw new Error('target-git-url-invalid');
|
||||
}
|
||||
if (parsed.protocol === 'https:' && parsed.username !== '') {
|
||||
throw new Error('target-git-url-contains-credential');
|
||||
}
|
||||
host = parsed.hostname;
|
||||
pathname = parsed.pathname;
|
||||
}
|
||||
|
||||
const parts = pathname
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\.git$/, '')
|
||||
.split('/')
|
||||
.filter((part: string): boolean => part.length > 0);
|
||||
if (host.length === 0 || parts.length !== 2) {
|
||||
throw new Error('target-git-url-invalid');
|
||||
}
|
||||
return { host: host.toLowerCase(), owner: safeName(parts[0] ?? '', 'repository-owner') };
|
||||
}
|
||||
|
||||
export function deriveBrainTarget(
|
||||
registrySource: string,
|
||||
targetGitUrl: string,
|
||||
brainNamespace: string,
|
||||
): BrainTarget {
|
||||
const target = parseGitTarget(targetGitUrl);
|
||||
const resolved = parseCredentialEstateRegistry(registrySource).resolveByHost(target.host);
|
||||
if (resolved === undefined) {
|
||||
throw new Error(`estate-host-unmapped: ${target.host}`);
|
||||
}
|
||||
const owner = safeName(brainNamespace, 'brain-namespace');
|
||||
const repo = `${owner}/mosaic-brain`;
|
||||
return {
|
||||
estate: resolved.estate,
|
||||
host: target.host,
|
||||
owner,
|
||||
repo,
|
||||
cloneUrl: `${resolved.host.apiBaseUrl}/${repo}.git`,
|
||||
};
|
||||
}
|
||||
|
||||
function syncFile(path: string): void {
|
||||
const descriptor = openSync(path, 'r');
|
||||
try {
|
||||
fsyncSync(descriptor);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrainSkeleton(root: string): { readonly created: readonly string[] } {
|
||||
const created: string[] = [];
|
||||
if (existsSync(root)) {
|
||||
const rootStatus = lstatSync(root);
|
||||
if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) {
|
||||
throw new Error('brain-layout-root-unsafe');
|
||||
}
|
||||
}
|
||||
for (const directory of BRAIN_DIRECTORIES) {
|
||||
const path = join(root, directory);
|
||||
if (existsSync(path)) {
|
||||
const status = lstatSync(path);
|
||||
if (!status.isDirectory() || status.isSymbolicLink()) {
|
||||
throw new Error('brain-layout-directory-unsafe');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(root, { recursive: true });
|
||||
for (const directory of BRAIN_DIRECTORIES) {
|
||||
const path = join(root, directory);
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(path, { recursive: true });
|
||||
created.push(path);
|
||||
}
|
||||
const placeholder = join(path, '.gitkeep');
|
||||
if (!existsSync(placeholder)) {
|
||||
writeFileSync(placeholder, '', { encoding: 'utf8', mode: 0o644, flag: 'wx' });
|
||||
syncFile(placeholder);
|
||||
created.push(placeholder);
|
||||
} else if (!lstatSync(placeholder).isFile() || lstatSync(placeholder).isSymbolicLink()) {
|
||||
throw new Error('brain-layout-placeholder-unsafe');
|
||||
}
|
||||
}
|
||||
|
||||
const ignorePath = join(root, '.gitignore');
|
||||
if (existsSync(ignorePath)) {
|
||||
const ignoreStatus = lstatSync(ignorePath);
|
||||
if (!ignoreStatus.isFile() || ignoreStatus.isSymbolicLink()) {
|
||||
throw new Error('brain-layout-ignore-unsafe');
|
||||
}
|
||||
}
|
||||
const existing = existsSync(ignorePath)
|
||||
? readFileSync(ignorePath, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.filter((line: string): boolean => line.length > 0)
|
||||
: [];
|
||||
const merged = [...existing];
|
||||
for (const rule of GITIGNORE_RULES) {
|
||||
if (!merged.includes(rule)) merged.push(rule);
|
||||
}
|
||||
const content = `${merged.join('\n')}\n`;
|
||||
if (!existsSync(ignorePath) || readFileSync(ignorePath, 'utf8') !== content) {
|
||||
const temporary = `${ignorePath}.tmp-${process.pid}-${randomUUID()}`;
|
||||
writeFileSync(temporary, content, { encoding: 'utf8', mode: 0o644, flag: 'wx' });
|
||||
syncFile(temporary);
|
||||
renameSync(temporary, ignorePath);
|
||||
created.push(ignorePath);
|
||||
}
|
||||
return { created };
|
||||
}
|
||||
|
||||
function indeterminate(reasonCode: string): CredentialAssessment {
|
||||
return {
|
||||
outcome: 'indeterminate',
|
||||
exitCode: 30,
|
||||
reasonCode,
|
||||
diagnostic: `indeterminate: ${reasonCode}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function assessCredentialResult(
|
||||
source: string,
|
||||
expectedSubject?: {
|
||||
readonly identity: string;
|
||||
readonly estate: string;
|
||||
readonly host: string;
|
||||
readonly repo: string;
|
||||
},
|
||||
): CredentialAssessment {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
return indeterminate('unexpected-provider-shape');
|
||||
}
|
||||
const parsed = credentialResultSchema.safeParse(raw);
|
||||
if (!parsed.success) return indeterminate('unexpected-provider-shape');
|
||||
|
||||
if (
|
||||
expectedSubject !== undefined &&
|
||||
(parsed.data.subject.identity !== expectedSubject.identity ||
|
||||
parsed.data.subject.estate !== expectedSubject.estate ||
|
||||
parsed.data.subject.host !== expectedSubject.host ||
|
||||
parsed.data.subject.repo !== expectedSubject.repo)
|
||||
) {
|
||||
return indeterminate('unexpected-provider-shape');
|
||||
}
|
||||
|
||||
const expectedExit = TERMINAL_EXITS[parsed.data.outcome];
|
||||
if (parsed.data.exitCode !== expectedExit) {
|
||||
return indeterminate('unexpected-provider-shape');
|
||||
}
|
||||
|
||||
const stableClass = STABLE_REASON_CLASSES[parsed.data.reason.code];
|
||||
if (stableClass !== undefined && stableClass !== parsed.data.outcome) {
|
||||
return indeterminate('unexpected-provider-shape');
|
||||
}
|
||||
|
||||
const identity = parsed.data.evidence.providerIdentity;
|
||||
if (identity !== null) {
|
||||
if (!identity.contentType.toLowerCase().startsWith('application/json')) {
|
||||
return indeterminate('unexpected-content-type');
|
||||
}
|
||||
if (identity.login !== parsed.data.subject.identity) {
|
||||
return {
|
||||
outcome: 'refused',
|
||||
exitCode: 10,
|
||||
reasonCode: 'provider-identity-mismatch',
|
||||
diagnostic: 'refused: provider-identity-mismatch',
|
||||
};
|
||||
}
|
||||
}
|
||||
if (parsed.data.outcome === 'ok') {
|
||||
const permission = parsed.data.evidence.repositoryPermission;
|
||||
const differential = parsed.data.evidence.writeDifferential;
|
||||
if (
|
||||
identity === null ||
|
||||
permission === null ||
|
||||
differential === null ||
|
||||
!permission.contentType.toLowerCase().startsWith('application/json') ||
|
||||
differential.transportPrincipal !== parsed.data.subject.identity ||
|
||||
parsed.data.mutation !== 'none' ||
|
||||
parsed.data.audit.state !== 'sealed' ||
|
||||
parsed.data.audit.journalId === null
|
||||
) {
|
||||
return indeterminate('readback-missing');
|
||||
}
|
||||
}
|
||||
if (
|
||||
parsed.data.reason.code === 'provider-identity-mismatch' &&
|
||||
(identity === null || identity.login === parsed.data.subject.identity)
|
||||
) {
|
||||
return indeterminate('unexpected-provider-shape');
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: parsed.data.outcome,
|
||||
exitCode: expectedExit,
|
||||
reasonCode: parsed.data.reason.code,
|
||||
diagnostic: `${parsed.data.outcome}: ${parsed.data.reason.code}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function assessResolverParity(
|
||||
gitSource: string,
|
||||
apiSource: string,
|
||||
): ResolverParityAssessment {
|
||||
const git = assessCredentialResult(gitSource);
|
||||
const api = assessCredentialResult(apiSource);
|
||||
if (git.outcome === 'refused' && api.outcome === 'refused' && git.reasonCode === api.reasonCode) {
|
||||
return {
|
||||
...git,
|
||||
gitReasonCode: git.reasonCode,
|
||||
apiReasonCode: api.reasonCode,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...indeterminate('permission-evidence-disagrees'),
|
||||
gitReasonCode: git.reasonCode,
|
||||
apiReasonCode: api.reasonCode,
|
||||
};
|
||||
}
|
||||
|
||||
function filesBelow(root: string): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
const result: string[] = [];
|
||||
const walk = (directory: string): void => {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name);
|
||||
if (entry.isDirectory()) walk(path);
|
||||
else if (entry.isFile()) result.push(path);
|
||||
else result.push(path);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return result.sort((left: string, right: string): number => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function ownerIsValid(
|
||||
resolution: MigrationOwnerResolution,
|
||||
lane: string,
|
||||
laneActive: boolean,
|
||||
): boolean {
|
||||
if (
|
||||
resolution.verdict !== 'resolved' ||
|
||||
resolution.principal === null ||
|
||||
resolution.authority === null ||
|
||||
resolution.authority.endpoint.length === 0 ||
|
||||
resolution.authority.contentType !== 'application/json'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const principal = resolution.principal;
|
||||
const normalized = principal.name.normalize('NFKC');
|
||||
if (normalized !== principal.name) return false;
|
||||
const grammars: Readonly<Record<string, RegExp>> = {
|
||||
'active-lane': /^lane:[a-z0-9][a-z0-9-]*$/,
|
||||
'durable-team': /^team:[a-z0-9][a-z0-9-]*$/,
|
||||
'durable-human': /^user:[a-z0-9][a-z0-9-]*$/,
|
||||
'durable-queue': /^queue:[a-z0-9][a-z0-9-]*$/,
|
||||
};
|
||||
const grammar = grammars[principal.kind];
|
||||
if (grammar === undefined || !grammar.test(normalized)) return false;
|
||||
if (principal.kind === 'active-lane') {
|
||||
return laneActive && principal.name === `lane:${lane}`;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function migrationDigest(sourceRoot: string, path: string): string {
|
||||
const stat = lstatSync(path);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('migration-source-not-regular');
|
||||
const key = relative(sourceRoot, path).split(sep).join('/');
|
||||
return createHash('sha256').update(key).update('\0').update(readFileSync(path)).digest('hex');
|
||||
}
|
||||
|
||||
function migrationCandidate(
|
||||
sourceRoot: string,
|
||||
brainRoot: string,
|
||||
path: string,
|
||||
kind: 'lane' | 'seat',
|
||||
seat: string,
|
||||
lane: string,
|
||||
): MigrationCandidate {
|
||||
const digest = migrationDigest(sourceRoot, path).slice(0, 16);
|
||||
const name = `${digest}-${basename(path)}`;
|
||||
const destination =
|
||||
kind === 'lane'
|
||||
? join(brainRoot, 'lanes', lane, 'findings', 'imports', name)
|
||||
: join(brainRoot, 'agents', seat, 'state', 'imports', name);
|
||||
return {
|
||||
source: path,
|
||||
destination,
|
||||
archive: join(brainRoot, 'archives', 'imports', kind, name),
|
||||
kind,
|
||||
};
|
||||
}
|
||||
|
||||
function secretShapedPath(path: string): boolean {
|
||||
const name = basename(path);
|
||||
return (
|
||||
name === '.env' ||
|
||||
name === 'credentials.json' ||
|
||||
name.endsWith('.token') ||
|
||||
name.endsWith('.key') ||
|
||||
name.endsWith('.pem')
|
||||
);
|
||||
}
|
||||
|
||||
function reportAll(paths: readonly string[], reason: string): MigrationReport[] {
|
||||
return paths.map((path: string): MigrationReport => ({ path, reason }));
|
||||
}
|
||||
|
||||
export function discoverBrainMigration(
|
||||
input: {
|
||||
readonly sourceRoot: string;
|
||||
readonly brainRoot: string;
|
||||
readonly seat: string;
|
||||
readonly lane: string;
|
||||
readonly laneActive: boolean;
|
||||
},
|
||||
resolveOwner?: (lane: string) => MigrationOwnerResolution,
|
||||
): MigrationPlan {
|
||||
const seat = safeName(input.seat, 'seat');
|
||||
const lane = safeName(input.lane, 'lane');
|
||||
const laneRoot = join(input.sourceRoot, 'lanes', lane);
|
||||
const seatRoot = join(input.sourceRoot, 'agents', seat);
|
||||
const laneFiles = filesBelow(laneRoot);
|
||||
const seatFiles = filesBelow(seatRoot);
|
||||
const selected = new Set([...laneFiles, ...seatFiles]);
|
||||
const all = filesBelow(input.sourceRoot);
|
||||
const unsupported = all.filter((path: string): boolean => !selected.has(path));
|
||||
|
||||
let ownerResolution: MigrationOwnerResolution = {
|
||||
verdict: 'not-measured',
|
||||
reasonCode: 'owner-resolver-unavailable',
|
||||
principal: null,
|
||||
authority: null,
|
||||
};
|
||||
if (resolveOwner !== undefined) {
|
||||
try {
|
||||
ownerResolution = resolveOwner(lane);
|
||||
} catch {
|
||||
ownerResolution = {
|
||||
verdict: 'not-measured',
|
||||
reasonCode: 'owner-resolver-failed',
|
||||
principal: null,
|
||||
authority: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!ownerIsValid(ownerResolution, lane, input.laneActive)) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
candidates: [],
|
||||
reported: reportAll(
|
||||
all,
|
||||
'Migration requires a source-of-truth-resolved named durable owner; caller assertions are not evidence.',
|
||||
),
|
||||
owner: null,
|
||||
};
|
||||
}
|
||||
|
||||
const candidates: MigrationCandidate[] = [];
|
||||
const reported: MigrationReport[] = reportAll(
|
||||
unsupported,
|
||||
'Ownership or supported migration shape was not established; retained and reported.',
|
||||
);
|
||||
for (const path of laneFiles) {
|
||||
if (secretShapedPath(path)) {
|
||||
reported.push({
|
||||
path,
|
||||
reason: 'Secret-shaped state is forbidden in the brain; retained and reported.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
candidates.push(
|
||||
migrationCandidate(input.sourceRoot, input.brainRoot, path, 'lane', seat, lane),
|
||||
);
|
||||
} catch {
|
||||
reported.push({ path, reason: 'Lane state was not a regular file; retained and reported.' });
|
||||
}
|
||||
}
|
||||
for (const path of seatFiles) {
|
||||
if (secretShapedPath(path)) {
|
||||
reported.push({
|
||||
path,
|
||||
reason: 'Secret-shaped state is forbidden in the brain; retained and reported.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
candidates.push(
|
||||
migrationCandidate(input.sourceRoot, input.brainRoot, path, 'seat', seat, lane),
|
||||
);
|
||||
} catch {
|
||||
reported.push({ path, reason: 'Seat state was not a regular file; retained and reported.' });
|
||||
}
|
||||
}
|
||||
return { status: 'ready', candidates, reported, owner: ownerResolution.principal };
|
||||
}
|
||||
|
||||
function isContained(root: string, path: string): boolean {
|
||||
const absoluteRoot = resolve(root);
|
||||
const absolutePath = resolve(path);
|
||||
return absolutePath === absoluteRoot || absolutePath.startsWith(`${absoluteRoot}${sep}`);
|
||||
}
|
||||
|
||||
function assertSafeDestinationAncestors(root: string, destination: string): void {
|
||||
if (!isContained(root, destination)) throw new Error('migration-destination-escaped-brain');
|
||||
if (!existsSync(root)) mkdirSync(root, { recursive: true });
|
||||
const rootStatus = lstatSync(root);
|
||||
if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) {
|
||||
throw new Error('migration-destination-ancestor-unsafe');
|
||||
}
|
||||
const parts = relative(root, dirname(destination)).split(sep).filter(Boolean);
|
||||
let cursor = root;
|
||||
for (const part of parts) {
|
||||
cursor = join(cursor, part);
|
||||
if (!existsSync(cursor)) continue;
|
||||
const status = lstatSync(cursor);
|
||||
if (!status.isDirectory() || status.isSymbolicLink()) {
|
||||
throw new Error('migration-destination-ancestor-unsafe');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyVerified(source: string, destination: string, brainRoot: string): boolean {
|
||||
assertSafeDestinationAncestors(brainRoot, destination);
|
||||
mkdirSync(dirname(destination), { recursive: true });
|
||||
assertSafeDestinationAncestors(brainRoot, destination);
|
||||
if (existsSync(destination)) {
|
||||
const status = lstatSync(destination);
|
||||
if (!status.isFile() || status.isSymbolicLink()) {
|
||||
throw new Error('append-only-destination-unsafe');
|
||||
}
|
||||
const sourceDigest = createHash('sha256').update(readFileSync(source)).digest('hex');
|
||||
const destinationDigest = createHash('sha256').update(readFileSync(destination)).digest('hex');
|
||||
if (sourceDigest !== destinationDigest) throw new Error('append-only-collision');
|
||||
return false;
|
||||
}
|
||||
const temporary = `${destination}.tmp-${process.pid}-${randomUUID()}`;
|
||||
try {
|
||||
copyFileSync(source, temporary, fsConstants.COPYFILE_EXCL);
|
||||
const temporaryStatus = lstatSync(temporary);
|
||||
if (!temporaryStatus.isFile() || temporaryStatus.isSymbolicLink()) {
|
||||
throw new Error('migration-copy-target-unsafe');
|
||||
}
|
||||
syncFile(temporary);
|
||||
const sourceDigest = createHash('sha256').update(readFileSync(source)).digest('hex');
|
||||
const copiedDigest = createHash('sha256').update(readFileSync(temporary)).digest('hex');
|
||||
if (sourceDigest !== copiedDigest) throw new Error('migration-copy-verification-failed');
|
||||
assertSafeDestinationAncestors(brainRoot, destination);
|
||||
linkSync(temporary, destination);
|
||||
syncFile(destination);
|
||||
return true;
|
||||
} finally {
|
||||
rmSync(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateBrainState(
|
||||
plan: MigrationPlan,
|
||||
publish: (brainRoot: string, paths: readonly string[]) => MigrationPublishEvidence,
|
||||
brainRoot: string,
|
||||
): MigrationResult {
|
||||
if (plan.status !== 'ready' || plan.candidates.length === 0) {
|
||||
return {
|
||||
status: 'reported',
|
||||
migrated: [],
|
||||
reported: plan.reported,
|
||||
publish: null,
|
||||
};
|
||||
}
|
||||
|
||||
const created: string[] = [];
|
||||
const published: string[] = [];
|
||||
let publicationAttempted = false;
|
||||
let evidence: MigrationPublishEvidence;
|
||||
try {
|
||||
for (const candidate of plan.candidates) {
|
||||
if (
|
||||
!isContained(brainRoot, candidate.destination) ||
|
||||
!isContained(brainRoot, candidate.archive)
|
||||
) {
|
||||
throw new Error('migration-destination-escaped-brain');
|
||||
}
|
||||
if (copyVerified(candidate.source, candidate.destination, brainRoot)) {
|
||||
created.push(candidate.destination);
|
||||
}
|
||||
if (copyVerified(candidate.source, candidate.archive, brainRoot)) {
|
||||
created.push(candidate.archive);
|
||||
}
|
||||
published.push(candidate.destination, candidate.archive);
|
||||
}
|
||||
|
||||
publicationAttempted = true;
|
||||
evidence = publish(brainRoot, published);
|
||||
if (!COMMIT.test(evidence.commit) || !COMMIT.test(evidence.remoteHead) || !evidence.reachable) {
|
||||
throw new Error('remote reachability was not established');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Once publication is attempted its remote mutation state may be unknown.
|
||||
// Keep the local copies so the checkout does not silently diverge from a
|
||||
// commit that may already be reachable; sources always remain intact.
|
||||
if (!publicationAttempted) {
|
||||
for (const path of created.reverse()) rmSync(path, { force: true });
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : 'migration failed';
|
||||
return {
|
||||
status: 'failed',
|
||||
migrated: [],
|
||||
reported: [
|
||||
...plan.reported,
|
||||
...plan.candidates.map(
|
||||
(candidate: MigrationCandidate): MigrationReport => ({
|
||||
path: candidate.source,
|
||||
reason: `Migration retained source: ${detail}`,
|
||||
}),
|
||||
),
|
||||
],
|
||||
publish: null,
|
||||
};
|
||||
}
|
||||
|
||||
const migrated: MigrationCandidate[] = [];
|
||||
const removalReports: MigrationReport[] = [];
|
||||
for (const candidate of plan.candidates) {
|
||||
try {
|
||||
unlinkSync(candidate.source);
|
||||
migrated.push(candidate);
|
||||
} catch {
|
||||
removalReports.push({
|
||||
path: candidate.source,
|
||||
reason:
|
||||
'Published migration is reachable but source cleanup failed; retained and reported.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: removalReports.length === 0 ? 'migrated' : 'reported',
|
||||
migrated,
|
||||
reported: [...plan.reported, ...removalReports],
|
||||
publish: evidence,
|
||||
};
|
||||
}
|
||||
|
||||
function accessFinding(access: CredentialAssessment | null): BrainDoctorFinding | null {
|
||||
if (access === null) {
|
||||
return {
|
||||
code: 'brain-write-access-indeterminate',
|
||||
repairable: false,
|
||||
reasonCode: 'readback-missing',
|
||||
};
|
||||
}
|
||||
if (access.outcome === 'ok') return null;
|
||||
return {
|
||||
code: `brain-write-access-${access.outcome}`,
|
||||
repairable:
|
||||
access.outcome === 'refused' &&
|
||||
['permission-denied', 'no-token-for-identity'].includes(access.reasonCode),
|
||||
reasonCode: access.reasonCode,
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateBrainDoctor(
|
||||
observation: BrainDoctorObservation,
|
||||
expectedRemote: string,
|
||||
): readonly BrainDoctorFinding[] {
|
||||
const access = accessFinding(observation.access);
|
||||
if (!observation.rootExists) {
|
||||
return [
|
||||
{ code: 'brain-clone-missing', repairable: true, reasonCode: null },
|
||||
...(access === null ? [] : [access]),
|
||||
];
|
||||
}
|
||||
const findings: BrainDoctorFinding[] = [];
|
||||
if (!observation.gitRepository) {
|
||||
findings.push({ code: 'brain-not-git-repository', repairable: true, reasonCode: null });
|
||||
return findings;
|
||||
}
|
||||
if (observation.remote !== expectedRemote) {
|
||||
findings.push({ code: 'brain-remote-mismatch', repairable: true, reasonCode: null });
|
||||
}
|
||||
if (observation.branch !== 'main') {
|
||||
findings.push({ code: 'brain-branch-mismatch', repairable: false, reasonCode: null });
|
||||
}
|
||||
if (observation.dirty === true) {
|
||||
findings.push({ code: 'brain-uncommitted-state', repairable: false, reasonCode: null });
|
||||
}
|
||||
if (access !== null) findings.push(access);
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function planBrainDoctorFix(input: {
|
||||
readonly findings: readonly BrainDoctorFinding[];
|
||||
readonly target: BrainTarget;
|
||||
readonly identity: string;
|
||||
readonly root: string;
|
||||
}): readonly BrainDoctorAction[] {
|
||||
safeName(input.identity, 'identity');
|
||||
const actions: BrainDoctorAction[] = [];
|
||||
const priority: Readonly<Record<string, number>> = {
|
||||
'brain-write-access-refused': 0,
|
||||
'brain-clone-missing': 1,
|
||||
'brain-remote-mismatch': 2,
|
||||
};
|
||||
const ordered = [...input.findings].sort(
|
||||
(left: BrainDoctorFinding, right: BrainDoctorFinding): number =>
|
||||
(priority[left.code] ?? 99) - (priority[right.code] ?? 99),
|
||||
);
|
||||
for (const finding of ordered) {
|
||||
if (!finding.repairable) continue;
|
||||
if (finding.code === 'brain-clone-missing') {
|
||||
actions.push({
|
||||
program: 'git',
|
||||
args: ['clone', '--branch', 'main', '--single-branch', input.target.cloneUrl, input.root],
|
||||
findingCode: finding.code,
|
||||
});
|
||||
} else if (finding.code === 'brain-remote-mismatch') {
|
||||
actions.push({
|
||||
program: 'git',
|
||||
args: ['-C', input.root, 'remote', 'set-url', 'origin', input.target.cloneUrl],
|
||||
findingCode: finding.code,
|
||||
});
|
||||
} else if (finding.code === 'brain-write-access-refused') {
|
||||
actions.push({
|
||||
program: 'mosaic',
|
||||
args: [
|
||||
'cred',
|
||||
'grant',
|
||||
input.identity,
|
||||
'--estate',
|
||||
input.target.estate,
|
||||
'--host',
|
||||
input.target.host,
|
||||
'--repo',
|
||||
input.target.repo,
|
||||
'--permission',
|
||||
'write',
|
||||
'--json',
|
||||
],
|
||||
findingCode: finding.code,
|
||||
});
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function safeRelativePath(path: string): string[] | null {
|
||||
if (isAbsolute(path) || path.includes('\\')) return null;
|
||||
const parts = path.split('/').filter((part: string): boolean => part.length > 0);
|
||||
if (parts.length === 0 || parts.some((part: string): boolean => part === '.' || part === '..')) {
|
||||
return null;
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function classifyBrainWrite(input: {
|
||||
readonly path: string;
|
||||
readonly actor: string;
|
||||
readonly seat: string;
|
||||
readonly boardWriter?: string;
|
||||
}): BrainWritePolicy {
|
||||
const parts = safeRelativePath(input.path);
|
||||
if (parts === null || !SAFE_NAME.test(input.actor) || !SAFE_NAME.test(input.seat)) {
|
||||
return { allowed: false, mode: 'refused', reason: 'invalid-write-subject' };
|
||||
}
|
||||
if (parts[0] === 'lanes' && parts.length >= 3) {
|
||||
return { allowed: true, mode: 'append-only', reason: 'lane-content-is-findings' };
|
||||
}
|
||||
if (parts[0] === 'board') {
|
||||
if (input.boardWriter !== undefined && input.actor === input.boardWriter) {
|
||||
return { allowed: true, mode: 'single-writer', reason: 'named-board-writer' };
|
||||
}
|
||||
return { allowed: false, mode: 'refused', reason: 'board-writer-mismatch' };
|
||||
}
|
||||
if (parts[0] === 'agents' && parts.length >= 3) {
|
||||
if (parts[1] === input.seat && input.actor === input.seat) {
|
||||
return { allowed: true, mode: 'seat-writer', reason: 'seat-owned-state' };
|
||||
}
|
||||
return { allowed: false, mode: 'refused', reason: 'seat-writer-mismatch' };
|
||||
}
|
||||
return { allowed: false, mode: 'refused', reason: 'unsupported-write-path' };
|
||||
}
|
||||
@@ -29,6 +29,11 @@ import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
||||
import {
|
||||
defaultInstalledBrainDoctorOptions,
|
||||
runInstalledBrainDoctorCheck,
|
||||
} from './brain-doctor-check.js';
|
||||
import { systemCommandRunner } from './brain-store-runtime.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
const MAX_INSTALLED_TOOLS_BYTES = 256 * 1024;
|
||||
@@ -1257,8 +1262,11 @@ export function registerLaunchCommands(program: Command): void {
|
||||
});
|
||||
}
|
||||
|
||||
// `doctor` — the framework drift audit (bash script) PLUS the #869
|
||||
// Point-1 C5 lease-enforcement activation check (TS, reusing C1's
|
||||
// `doctor` — the framework drift audit (bash script), the #869
|
||||
// Point-1 C5 lease-enforcement activation check, and the #1051 per-estate
|
||||
// durable brain check. Both TS checks run before the bash audit and can
|
||||
// force a non-zero result for hard/indeterminate failures.
|
||||
// The lease check reuses C1's
|
||||
// `leaseEnforcementActivatable()` and C3's `checkBrokerSupervisorHealth()`).
|
||||
// Kept out of the generic `directCommands` loop above because this check
|
||||
// must run and report BEFORE the bash script's own exit, and must be able
|
||||
@@ -1267,14 +1275,29 @@ export function registerLaunchCommands(program: Command): void {
|
||||
// undiagnosed (see lease-doctor-check.ts docstring).
|
||||
program
|
||||
.command('doctor')
|
||||
.description('Health audit — detect drift, missing files, and #869 lease-activation gaps')
|
||||
.description('Health audit — detect drift, lease gaps, and per-estate brain defects')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.action(async (_opts: unknown, cmd: Command) => {
|
||||
checkMosaicHome();
|
||||
const leaseCheck = await runLeaseEnforcementDoctorCheck();
|
||||
const leaseCheckFailed = printLeaseDoctorCheck(leaseCheck);
|
||||
runDoctorScriptAndExit(fwScript('mosaic-doctor'), cmd.args, leaseCheckFailed);
|
||||
const fix = cmd.args.includes('--fix');
|
||||
const brainCheck = runInstalledBrainDoctorCheck(
|
||||
defaultInstalledBrainDoctorOptions(fix),
|
||||
systemCommandRunner,
|
||||
);
|
||||
for (const line of brainCheck.lines) {
|
||||
(brainCheck.status === 'ok' ? console.log : console.error)(line);
|
||||
}
|
||||
const brainCheckFailed =
|
||||
brainCheck.status === 'error' ||
|
||||
(brainCheck.status === 'warn' && cmd.args.includes('--fail-on-warn'));
|
||||
runDoctorScriptAndExit(
|
||||
fwScript('mosaic-doctor'),
|
||||
cmd.args,
|
||||
leaseCheckFailed || brainCheckFailed,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user