Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
913f00770f | ||
|
|
58ada98d2b | ||
|
|
d66e91b1f2 | ||
|
|
df705828a4 | ||
|
|
f33bd0da96 | ||
|
|
0e2eef1c12 | ||
|
|
4c4d16131a | ||
|
|
ff3f0d29f1 | ||
|
|
378bc1afe3 | ||
|
|
e5d5c8495a | ||
|
|
3edde464b3 | ||
|
|
99e28d4100 | ||
|
|
7c4a4a4a3a | ||
|
|
049982d30e | ||
|
|
4904d4553c |
@@ -0,0 +1,139 @@
|
|||||||
|
# C1 detector gate. The fixture itself is intentionally RED; CI is green only
|
||||||
|
# when its exact phase verdicts/reasons match the versioned expected-RED manifest.
|
||||||
|
when:
|
||||||
|
- event: [pull_request, manual]
|
||||||
|
- event: push
|
||||||
|
branch: [next, main]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
greenfield-case-denominator-init:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh init cases \
|
||||||
|
tools/fixtures/greenfield-expected-red.tsv "$coverage_root" \
|
||||||
|
"$coverage_run"
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh init arms \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt "$coverage_root" \
|
||||||
|
"$coverage_run"
|
||||||
|
|
||||||
|
greenfield-git-present:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
depends_on:
|
||||||
|
- greenfield-case-denominator-init
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
bash tools/e2e-install-test.sh --lane next --source checkout --git present \
|
||||||
|
> /tmp/greenfield-git-present.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-git-present.log
|
||||||
|
bash tools/verify-greenfield-expected-red.sh \
|
||||||
|
next-git-present /tmp/greenfield-git-present.log "$fixture_status"
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark cases \
|
||||||
|
tools/fixtures/greenfield-expected-red.tsv "$coverage_root" \
|
||||||
|
"$coverage_run" next-git-present
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark arms \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt "$coverage_root" \
|
||||||
|
"$coverage_run" greenfield-git-present
|
||||||
|
|
||||||
|
greenfield-main-git-present:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
depends_on:
|
||||||
|
- greenfield-case-denominator-init
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
bash tools/e2e-install-test.sh --lane main --source checkout --git present \
|
||||||
|
> /tmp/greenfield-main-git-present.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-main-git-present.log
|
||||||
|
bash tools/verify-greenfield-expected-red.sh \
|
||||||
|
main-git-present /tmp/greenfield-main-git-present.log "$fixture_status"
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark cases \
|
||||||
|
tools/fixtures/greenfield-expected-red.tsv "$coverage_root" \
|
||||||
|
"$coverage_run" main-git-present
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark arms \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt "$coverage_root" \
|
||||||
|
"$coverage_run" greenfield-main-git-present
|
||||||
|
|
||||||
|
greenfield-remote-installer-contract:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
depends_on:
|
||||||
|
- greenfield-case-denominator-init
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
expected="$(awk 'NF {print $1; exit}' tools/install.sh.sha256)"
|
||||||
|
actual="$(sha256sum tools/install.sh | awk '{print $1}')"
|
||||||
|
test "$actual" = "$expected"
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
MOSAIC_FIXTURE_INSTALLER_URL="https://git.mosaicstack.dev/mosaicstack/stack/raw/commit/${CI_COMMIT_SHA}/tools/install.sh" \
|
||||||
|
MOSAIC_FIXTURE_INSTALLER_SHA256="$expected" \
|
||||||
|
MOSAIC_FIXTURE_SOURCE_COMMIT="${CI_COMMIT_SHA}" \
|
||||||
|
bash tools/e2e-install-test.sh --lane next --source remote --git present \
|
||||||
|
> /tmp/greenfield-remote.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-remote.log
|
||||||
|
bash tools/verify-greenfield-expected-red.sh \
|
||||||
|
next-git-present /tmp/greenfield-remote.log "$fixture_status"
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark arms \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt "$coverage_root" \
|
||||||
|
"$coverage_run" greenfield-remote-installer-contract
|
||||||
|
|
||||||
|
greenfield-git-absent:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
depends_on:
|
||||||
|
- greenfield-case-denominator-init
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
set +e
|
||||||
|
MOSAIC_GREENFIELD_CONTAINER=1 \
|
||||||
|
bash tools/e2e-install-test.sh --lane next --source checkout --git absent \
|
||||||
|
> /tmp/greenfield-git-absent.log 2>&1
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
cat /tmp/greenfield-git-absent.log
|
||||||
|
bash tools/verify-greenfield-expected-red.sh \
|
||||||
|
next-git-absent /tmp/greenfield-git-absent.log "$fixture_status"
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark cases \
|
||||||
|
tools/fixtures/greenfield-expected-red.tsv "$coverage_root" \
|
||||||
|
"$coverage_run" next-git-absent
|
||||||
|
bash tools/verify-greenfield-execution-coverage.sh mark arms \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt "$coverage_root" \
|
||||||
|
"$coverage_run" greenfield-git-absent
|
||||||
|
|
||||||
|
greenfield-case-denominator:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
# Publish exact execution coverage after the full matrix, even if a case failed.
|
||||||
|
depends_on:
|
||||||
|
- greenfield-git-present
|
||||||
|
- greenfield-main-git-present
|
||||||
|
- greenfield-remote-installer-contract
|
||||||
|
- greenfield-git-absent
|
||||||
|
when:
|
||||||
|
- status: [success, failure]
|
||||||
|
commands:
|
||||||
|
- |
|
||||||
|
coverage_run="${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}"
|
||||||
|
coverage_root=.mosaic-test-work/greenfield-execution-coverage
|
||||||
|
bash tools/verify-greenfield-execution-coverage-gate.sh \
|
||||||
|
tools/verify-greenfield-execution-coverage.sh \
|
||||||
|
tools/fixtures/greenfield-expected-red.tsv \
|
||||||
|
tools/fixtures/greenfield-expected-arms.txt \
|
||||||
|
"$coverage_root" "$coverage_run"
|
||||||
@@ -7,20 +7,21 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
|||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
d="$(mktemp -d)" && trap 'rm -rf "$d"' EXIT && curl -fsSL -o "$d/install.sh" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh -o "$d/install.sh.sha256" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh.sha256 && (cd "$d" && test -s install.sh && sha256sum -c install.sh.sha256 && bash install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the direct URL:
|
The published installer body must be non-empty and match its versioned SHA-256
|
||||||
|
sidecar before it executes. A failed fetch, HTTP-200 empty body, or digest
|
||||||
```bash
|
mismatch is fatal. Because both files come from the same repository and trust
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
domain, this detects corruption or inconsistent publication—not repository or
|
||||||
```
|
server compromise. Independently signed release provenance is explicitly
|
||||||
|
deferred by the greenfield-install PRD.
|
||||||
|
|
||||||
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL …) --yes # Accept all defaults
|
(cd "$d" && bash install.sh --yes) # Accept all defaults
|
||||||
bash <(curl -fsSL …) --yes --no-auto-launch # Install only, skip wizard
|
(cd "$d" && bash install.sh --yes --no-auto-launch) # Install only, skip wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs both components:
|
This installs both components:
|
||||||
@@ -30,6 +31,16 @@ This installs both components:
|
|||||||
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
|
||||||
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
|
||||||
|
|
||||||
|
### Install lanes
|
||||||
|
|
||||||
|
| Lane | Command | Use when | Source |
|
||||||
|
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
|
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
|
||||||
|
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Exact `@next` CLI/gateway versions + pinned `next` framework commit; pinned-source fallback |
|
||||||
|
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
|
||||||
|
|
||||||
|
`--next` selects the prerelease integration lane. It installs the exact CLI/gateway versions resolved from the aligned `@next` tags, and pins the framework archive to the resolved `next` commit. If the registry path fails, it builds from that pinned source. An explicit `--ref` or `MOSAIC_REF` wins and selects source mode.
|
||||||
|
|
||||||
After install, the wizard runs automatically or you can invoke it manually:
|
After install, the wizard runs automatically or you can invoke it manually:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -38,10 +49,14 @@ mosaic wizard # Full guided setup (gateway install → verify)
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js ≥ 20
|
- Linux x86_64 with glibc (Debian is the greenfield CI platform; musl/Alpine, macOS, and ARM64 currently fail as unsupported)
|
||||||
- npm (for global @mosaicstack/mosaic install)
|
- Node.js ≥ 20 and npm ≥ 9
|
||||||
|
- `bash`, `curl`, `git`, `python3`, `tar`, and standard core utilities (`awk`, `df`, `find`, `flock`, `grep`, `install`, `realpath`, `sed`, `sha256sum`, `stat`, `sync`)
|
||||||
|
- At least 256 MiB free disk and 1,000 free inodes at the npm prefix
|
||||||
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
- One or more runtimes: [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex](https://github.com/openai/codex), [OpenCode](https://opencode.ai), or [Pi](https://github.com/mariozechner/pi-coding-agent)
|
||||||
|
|
||||||
|
The installer evaluates canonical phases P0–P9 and does not print `Done.` unless every committed postcondition passes. A failed phase exits non-zero, names the phase, and points to its durable journal under `${XDG_STATE_HOME:-~/.local/state}/mosaic/install/`. See [Installer state machine and recovery](docs/guides/installer-state-machine.md).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Launching Agent Sessions
|
### Launching Agent Sessions
|
||||||
@@ -334,16 +349,10 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
|||||||
|
|
||||||
## Upgrading
|
## Upgrading
|
||||||
|
|
||||||
Run the installer again — it handles upgrades automatically:
|
Run the same verified installer flow again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
d="$(mktemp -d)" && trap 'rm -rf "$d"' EXIT && curl -fsSL -o "$d/install.sh" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh -o "$d/install.sh.sha256" https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh.sha256 && (cd "$d" && test -s install.sh && sha256sum -c install.sh.sha256 && bash install.sh)
|
||||||
```
|
|
||||||
|
|
||||||
Or use the direct URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the CLI:
|
Or use the CLI:
|
||||||
@@ -358,15 +367,17 @@ The CLI also performs a background update check on every invocation (cached for
|
|||||||
### Installer Flags
|
### Installer Flags
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash tools/install.sh --check # Version check only
|
bash tools/install.sh --check # Side-effect-free P0-P8 postcondition check
|
||||||
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
bash tools/install.sh --framework # Framework only (skip npm CLI)
|
||||||
bash tools/install.sh --cli # npm CLI only (skip framework)
|
bash tools/install.sh --cli # npm CLI only (skip framework)
|
||||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --next # Prerelease lane: exact @next versions + pinned-source fallback
|
||||||
|
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
|
||||||
|
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||||
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
bash tools/install.sh --yes # Non-interactive, accept all defaults
|
||||||
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage. `--check` reports one PASS/FAIL row for each P0–P8 predicate and exits non-zero if any row fails; it does not create the npm prefix, lock, journal, manifest, or runtime files.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
+40
-53
@@ -437,59 +437,6 @@ Canonical checkpoint/handoff payloads, exactly-once connector receipts, concrete
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Governed fleet credential lifecycle (`mosaic cred`, #1045)
|
|
||||||
|
|
||||||
### Problem and objective
|
|
||||||
|
|
||||||
Fleet credentials are issued, wired, resolved, granted, validated, rotated, and revoked through unrelated scripts and manual provider actions. The split has produced silent fallback to a human/shared principal, missing runtime identity, cross-estate login resolution, incomplete permission checks, and non-auditable grants. The objective is one mechanical, durable, systemic `mosaic cred` path that decides both what a fleet seat may do and which provider identity it acts as.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
Phase 1 governs the existing per-identity Gitea token store and Tea login registration. VaultWarden is explicitly out for the agent tier and is not a backend option in this workstream. Certificate-backed identity and short-lived broker-issued credentials remain later phases behind the same caller contract.
|
|
||||||
|
|
||||||
### Normative requirements
|
|
||||||
|
|
||||||
1. `CRED-REQ-01`: The CLI SHALL expose `provision`, `wire`, `grant`, `get`, `validate`, `whoami`, `list`, `rotate`, `revoke`, and `audit`. Grant and validate SHALL conform to [`docs/credentials/GRANT-VALIDATE-CONTRACT.md`](./credentials/GRANT-VALIDATE-CONTRACT.md).
|
|
||||||
2. `CRED-REQ-02`: Every provider operation SHALL carry an explicit identity, estate, and host. Estate-to-host mapping SHALL come from strict non-secret configuration. Missing, ambiguous, inferred, or mismatched values SHALL refuse before credential resolution. Machine location SHALL grant no estate authority.
|
|
||||||
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither. At mint time, delegated Basic authority SHALL read its provider principal back, the minted token object SHALL read back exact scopes, and both the token binding and exact host-bound Tea record SHALL contain that same minted credential. Rollback SHALL read provider, token-store, and Tea state back and may report complete only when the pre-operation state is established exactly; failed or unverified cleanup is `indeterminate`/`rollback-incomplete`. Provision, rotate, and revoke SHALL serialize the complete provider/token-store/Tea transaction between cooperating `mosaic cred` processes under one fixed estate/host/identity advisory lock that caller-selected state roots cannot bypass. That same-UID-replaceable filesystem lock and the file-store generation preconditions provide optimistic concurrency only for cooperating `mosaic cred` mutators; neither is an authorization boundary nor atomic CAS against a hostile same-UID filesystem writer. Provider authority is the authorization boundary. Hostile same-UID direct filesystem mutation is explicitly out of scope for this phase and deferred. Runtime `/user` identity remeasurement is required only when the seat token already carries `read:user`; least-privilege tokens SHALL NOT be widened to service the instrument. A wrong-host or absent Tea login SHALL never fall back to a host default.
|
|
||||||
4. `CRED-REQ-04`: Under fleet context, unset or unresolvable identity SHALL fail closed identically in the git credential helper and API resolver. Interactive shared credentials remain available only through an explicit non-fleet/shared selection; absence SHALL never select them.
|
|
||||||
5. `CRED-REQ-05`: Token scope, repository permission, and organization/team role are independent layers. Provision, grant, and validate SHALL report each separately from provider evidence. No layer substitutes for another, and a permission widening at one layer SHALL not be described as least privilege because another layer is narrow.
|
|
||||||
6. `CRED-REQ-06`: Gitea token creation SHALL use an explicit delegated provisioning step because this provider requires Basic Auth. Password-equivalent provisioning material SHALL enter only through a protected control-plane runtime credential channel, never caller bearer storage, argv, ordinary environment, logs, or output.
|
|
||||||
7. `CRED-REQ-07`: Permission grants SHALL be accepted only after provider read-back of the named direct collaborator permission or, for team grants, organization membership, team membership, team-repository attachment, and subject effective permission.
|
|
||||||
8. `CRED-REQ-08`: `validate --repo` SHALL compute a side-effect-free write differential by result. One immutable credential resolution SHALL bind the declared subject's provider identity read-back, repository permission, and authenticated Git receive-pack advertisement. A distinct provider-confirmed read-only principal and an unauthenticated caller SHALL both be refused receive-pack in the same evaluation. Principal/handle disagreement SHALL be indeterminate, never refusal or success. The check SHALL create no ref or artifact and SHALL state that it does not prove a particular update will pass branch protection, hooks, races, or content policy.
|
|
||||||
9. `CRED-REQ-09`: All provider HTTP calls SHALL share one transport implementation for URL/host binding, TLS, User-Agent, content-type, JSON-shape validation, redaction, and bounded responses. A 2xx status alone SHALL never establish identity, scope, permission, grant, or revocation.
|
|
||||||
10. `CRED-REQ-10`: Operations SHALL return stable machine outcomes `ok`, `refused`, `error`, or `indeterminate`. Policy refusal, local operational failure, and incomplete/inconsistent evidence SHALL remain distinguishable. `provider-unavailable`, `identity-not-measured`, `identity-not-visible`, `identity-not-found`, and `credential-rejected` SHALL remain distinct diagnoses. Validation SHALL report capability from an in-scope probe separately from identity measurement. `/user` 401 is `credential-rejected`/refused; `/user` 403/404 plus successful in-scope capability is `identity-not-measured`, never a dead credential. A returned login mismatch is a binding refusal. No implemented operation may emit `identity-not-found`; that diagnosis requires a separately approved visibility-authorized inventory capability. Security callers SHALL fail closed on every outcome except `ok` without relabelling indeterminate evidence as a denial.
|
|
||||||
11. `CRED-REQ-11`: No command SHALL print a token, password, authorization header, fingerprint, partial secret, or secret-bearing provider body, including error paths. Secrets SHALL not appear in process argv. Phase-1 file storage SHALL remain private, symlink-safe, regular-file-only, test-overridable, and compatible with existing managed token consumers.
|
|
||||||
12. `CRED-REQ-12`: Every issue, provision, grant, rotate, revoke, and credential access SHALL be journaled with actor, subject, estate, host, repo/scope, operation, time, and non-secret provider evidence. The durable journal SHALL be opened and fsynced before the first mutation, append each mutation/read-back, and seal only after acceptance. Credential access SHALL durably record issuance start immediately before protected-fd disclosure; any partial write or post-write audit failure SHALL remain visibly `indeterminate` with mutation `unknown` or `applied`, never be relabelled as a pre-disclosure destination error. A journal seal SHALL remain staged and recovery-visible until its final rename and directory durability succeed; a final seal-commit fault SHALL revert to open classification and cannot leave an acceptance-bearing sealed-success object. Journal/audit write failure SHALL be fatal; an unsealed journal means incomplete/indeterminate work.
|
|
||||||
13. `CRED-REQ-13`: `wire` SHALL be idempotent and SHALL update the exact roster-derived `<identity>.env.generated` fleet projection so both identity axes survive restart. It SHALL authenticate the same explicit seat through a protected delegated credential channel and provider identity read-back before mutation, refuse actor/identity/path/roster disagreements, and never authorize from the shared Unix account. It SHALL not write linked-worktree git configuration or silently infer identity from pane/session names.
|
|
||||||
14. `CRED-REQ-14`: Rotate SHALL verify the new credential/provider identity before retiring the old credential. Revoke SHALL read back provider revocation/denial and preserve an auditable recovery record. A local file deletion or successful HTTP status is not revocation evidence.
|
|
||||||
15. `CRED-REQ-15`: Before the #1044 fail-closed resolver change is eligible to land, `mosaic cred validate` SHALL resolve every live HOMELAB mosaic-lane seat from `git.mosaicstack.dev` by provider read-back. Any unresolved seat HOLDS the fail-closed change; the implementation may not widen or restore shared fallback.
|
|
||||||
16. `CRED-REQ-16`: Provider claims SHALL record the estate, instance, endpoint, asserted content type, and decision-relevant object fields. Append-only provider status history SHALL be reduced to latest-per-context where current state is required.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
1. `AC-CRED-01`: Red-first tests prove unset identity, missing token, wrong estate, wrong host, wrong Tea login, and out-of-estate identity produce the same structured refusal class/reason on git and API resolution, with no shared credential read and no provider mutation.
|
|
||||||
2. `AC-CRED-02`: Provisioning against a provider fixture proves Basic Auth is required, bearer-only token minting is refused, both identity axes are established together or every completed provider/token-store/Tea change is compensated and read back to the exact pre-operation state, and exact token scopes are read back from the provider token object. Injected Tea cleanup failure returns `indeterminate`/`rollback-incomplete` and cannot claim mutation `none`; no cross-system atomic commit is claimed. A second cooperating same-identity transaction is refused across caller-selected state roots. A code-level security-model assertion pins that advisory flock as cooperative serialization, provider authority as the authorization boundary, generation preconditions as optimistic protection for cooperating mutators rather than atomic CAS, and hostile same-UID filesystem mutation as explicitly deferred.
|
|
||||||
3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope and repo grant cases cannot return `ok`; organization/team membership and team-repository attachment are additionally acceptance-bearing for team grants. A direct collaborator grant reports organization membership but does not require it, because direct collaborator permission and organization membership are intentionally independent provider layers.
|
|
||||||
4. `AC-CRED-04`: Validate proves provider identity and the write differential on the intended repository through one credential handle. The subject is accepted, a separately resolved provider-confirmed read-only principal is refused, and an unauthenticated caller is refused in the same invocation. A shared/wrong-principal fallback, independent subject lookups, invalid read-only control, evidence disagreement, unexpected content type/shape, or provider outage returns `indeterminate`, never success or policy refusal. Runtime exact scope is reported independently as `not-measured` when the current seat credential is not authorized to read its provider token object; NOT-MEASURED is neither pass nor failure and does not erase confirmed repository capability. Exact scope is acceptance-bearing at provision/rotate time, where delegated mint authority can read the token object.
|
|
||||||
5. `AC-CRED-05`: Audit/journal fault injection before and after each mutation proves write failure is fatal, open journals remain visible/recoverable, and no operation can claim success without a sealed journal and provider read-back. Protected credential output fault injection covers partial write, post-write append, and seal failure and preserves possibly-issued/applied truth in both DTO and durable journal.
|
|
||||||
6. `AC-CRED-06`: Adversarial output/argv tests seed distinct secret values through success, refusal, provider-error, parser-error, rollback, rotate, and revoke paths and find zero secret/partial/fingerprint occurrences in stdout, stderr, logs, audit, and child argv.
|
|
||||||
7. `AC-CRED-07`: Storage tests reject symlinked roots/files, non-regular files, permissive modes, traversal, stale generations from cooperating concurrent mutators, and production-store leakage into fixture tests. They do not claim atomic exclusion against hostile same-UID direct filesystem mutation. Existing canonical per-seat token consumers continue through the governed adapter.
|
|
||||||
8. `AC-CRED-08`: `wire` repeated twice is byte-idempotent, produces both required identity-axis values in the exact roster-derived generated environment, survives a fresh fleet projection/restart path, and leaves shared linked-worktree git config untouched. An unauthenticated caller, a caller authenticated as another seat, a caller-selected filename, or a file whose roster identity differs is refused before mutation.
|
|
||||||
9. `AC-CRED-09`: Rotate validates new identity/capabilities before retiring old material; injected failure leaves the previously valid credential usable and the journal open. Revoke is accepted only when provider read-back proves the credential no longer authenticates/authorizes.
|
|
||||||
10. `AC-CRED-10`: Every live HOMELAB mosaic-lane seat resolves from `git.mosaicstack.dev` before the #1044 fallback closes. The evidence names the complete seat population, provider endpoint/content type, and unresolved count; non-zero unresolved count blocks landing.
|
|
||||||
11. `AC-CRED-11`: Baseline typecheck/lint/format/tests, focused auth/permission abuse cases, independent code review, independent security review, and terminal-green HOMELAB Woodpecker CI pass on the exact reviewed head.
|
|
||||||
12. `AC-CRED-12`: Interim delivery to `next` is reported only as **believed-fixed, pending validation AND pending promotion to `main`**. Issues stay open until #1037 promotes the work and constitutional completion is independently verified.
|
|
||||||
|
|
||||||
### Constraints and dependencies
|
|
||||||
|
|
||||||
- C1 install-state-machine work merges first. This lane then re-takes base/head-bound measurements without redesigning or reworking code.
|
|
||||||
- MB-BRAIN-01 (#1051) consumes the grant/validate contract and may proceed against the published interface before implementation merge.
|
|
||||||
- The branch-model compatibility question for `next` remains escalated. No `done` claim, issue closure, or self-initiated promotion is permitted at the `next` checkpoint.
|
|
||||||
- `ASSUMPTION:` Phase-1 Gitea support is the only provider implementation in this slice; provider-neutral types preserve later adapters without pretending unimplemented providers are supported.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### High-Level System Diagram
|
### High-Level System Diagram
|
||||||
@@ -1421,3 +1368,43 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
|||||||
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
||||||
|
|
||||||
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Greenfield install correctness — C1 (#1050)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
A from-zero install can report success while leaving the target host unusable because the installer has no transactional state machine capable of certifying its own postconditions. C1 supplies the structural spine and red-first fixture; later cards repair the individual failed postconditions.
|
||||||
|
|
||||||
|
### Normative requirements
|
||||||
|
|
||||||
|
1. The installer SHALL implement the canonical P0–P9 numbering from the greenfield-install PRD v2: P0 Resolve context; P1 Preflight; P2 Acquire artifacts; P3 Install CLI; P4 Install framework + skills; P5 Identity; P6 Runtime linking / activation; P7 Services; P8 Shell discoverability; P9 Verify + commit. P2 is scoped to installer-distribution artifacts and SHALL NOT foreclose credentialed downstream acquisition. P5 owns validating any credential capability required by requested downstream work; P7 may provision credential-dependent resources only after that P5 postcondition commits.
|
||||||
|
2. Every phase SHALL declare preconditions, action, committed postconditions, and rollback. An unverifiable postcondition SHALL fail the install non-zero with the named phase and a remediation line; no best-effort failure may still certify success. P1's required-tool closure includes tools invoked by later phases, including `git`; a downstream prerequisite may not remain undeclared and degrade silently.
|
||||||
|
3. A durable mutation journal SHALL open before the first mutation and commit at P9. Fallible command output needed to diagnose a phase SHALL be journaled and surfaced, never discarded.
|
||||||
|
4. `--check` SHALL run exactly the P0–P8 postcondition predicates without mutation, report each phase PASS/FAIL, and exit non-zero if any predicate fails.
|
||||||
|
5. P4 SHALL consume a checkout-free, lane/versioned shipped-set declaration published by the installer. C1 SHALL NOT select among the currently disagreeing framework-payload, repository-root, sync-source, and W-jarvis populations; while no declaration exists, P4 reports `NOT-MEASURED / UNDECLARED` and remains blocking rather than fabricating a count. C5 owns the declaration's contents and containment/loadability fulfillment.
|
||||||
|
6. The from-zero fixture SHALL be lane-parametric, use Debian/glibc, run the documented install command as a non-root target user with an isolated HOME, and inherit no host credentials, npm cache, home directory, or runtime configuration.
|
||||||
|
7. The fixture SHALL select `next` with `--next` or `MOSAIC_NEXT=1` and assert the resolved lane version. Internal predicates use P3's absolute CLI path; shell discoverability is tested only at P8.
|
||||||
|
8. Fault injection after each P2–P8 phase SHALL prove either clean rollback or a durable, honestly reported resumable partial state, with no journal incorrectly left in progress.
|
||||||
|
9. Unsupported musl/Alpine and unavailable Docker SHALL fail loudly rather than skip as pass. The repository's installer tests SHALL nevertheless run in the canonical Alpine CI image by explicitly modeling a supported non-root/glibc target and using portable filesystem enumeration.
|
||||||
|
10. P0 SHALL bind the effective uid and username to the authoritative passwd HOME and shell and state/reject unsafe root or sudo-with-inherited-HOME privilege contexts.
|
||||||
|
11. Created paths SHALL satisfy phase-specific target owner/group and mode policy: P3 executables are not group/world writable, framework/runtime trees are not group/world writable, and identity/credential material is private.
|
||||||
|
12. The expected-RED comparator SHALL validate the complete manifest before selecting a case: exact case population, one exit and P0–P9 disposition per case, pinned require/forbid classes, and no malformed, duplicate, or unknown rows.
|
||||||
|
13. The published installer contract SHALL reject failed fetches, HTTP-success empty bodies, and digest mismatch, then execute the exact digest-verified body. The remote CI arm SHALL enumerate every payload-acquisition path and report a bound/found denominator. It SHALL bind both the installer body and the downstream stack framework/source it consumes to the same immutable CI commit, while retaining `--next` as the lane selector. Source resolution/acquisition SHALL fail closed, and the arm SHALL verify the realised source commit and archive digest after installation. Any out-of-scope unpinned sibling SHALL be named and counted rather than silently included in a broader pinning claim. `ASSUMPTION:` the configured repository's authenticated exact-commit endpoint is trusted to map that commit ID to the returned archive bytes; independent signed provenance/authenticity against repository or TLS trust-root compromise remains excluded by canonical greenfield-install PRD v2 §3.
|
||||||
|
14. Phase diagnostics SHALL be redacted before terminal or durable-log output. A seeded positive-control canary SHALL remain absent from observed argv, output, command logs, npm configuration, generated files, and shell history.
|
||||||
|
15. The CI fixture SHALL publish pipeline-level execution coverage for both the unconditional case set derived from the expected-RED manifest and the explicitly declared required pipeline-arm set, including the immutable remote-installer contract. Per-case and per-arm success markers SHALL be run-scoped, stored beneath an existing checkout-archive exclusion, and written only after that arm's verifier passes. A final step SHALL depend on the complete fixture matrix, run after prior success or failure, emit both `cases_defined=N cases_executed=M` and `arms_defined=N arms_executed=M`, and invoke the same testable aggregation helper whose complete case/arm PASS/FAIL truth table is covered. It SHALL fail unless both expected/executed name sets are exactly equal; missing, unexpected, stale, newly added unexecuted, or checkout-contaminating state SHALL fail closed. The archive-purity control SHALL bind to the production checkout-archive selector so deleting the production exclusion makes the control RED. Coverage tooling SHALL execute under both the canonical Alpine/BusyBox CI image and the Debian greenfield image; a green result from either runtime alone is insufficient portability evidence. Expected-set membership SHALL consume a fully materialized producer result so an early-closing consumer cannot turn a valid marker into a `pipefail` rejection.
|
||||||
|
|
||||||
|
### C1 acceptance criteria
|
||||||
|
|
||||||
|
1. The pre-C1 from-zero matrix records both discriminating controls: with `git` absent, the legacy installer still exits zero while P1 fails and skill sync degrades; with `git` present, P1 passes and the observed sync store/runtime links are 101/101. The C1 installer must fail at P1 before mutation when `git` is absent.
|
||||||
|
2. The discriminating P3 row passes: the binary exists at the expected absolute path and reports exactly the resolved `next` lane version, while P4, P5, and P8 fail.
|
||||||
|
3. The `--check` mutation negative control proves host fingerprints are byte-identical before and after observation.
|
||||||
|
4. Woodpecker executes and validates the expected RED fixture plus the immutable remote-installer contract; its pipeline-level coverage gate reports exact equality for the manifest-derived case set and the declared arm set, while skipped-case and skipped-remote-arm controls prove non-execution is red. C1 does not repair P4/P5/P8 or activate #869.
|
||||||
|
5. Negative controls prove manifest shrink/duplicates/unknown rows fail, unsafe P0/P3/P4/P5 contexts fail, the P2–P8 fault seam enters real actions rather than synthetic writes, empty/mismatched fetched bodies fail, and a deliberately emitted secret canary is redacted from every persisted/output population.
|
||||||
|
|
||||||
|
### Explicit exclusions and dependencies
|
||||||
|
|
||||||
|
- C2 owns P8/PATH, C3 owns P5/headless identity, C4 owns P6 activation policy, and C5 owns P4/skills.
|
||||||
|
- Main-lane execution is a promotion precondition owned by #1037; C1 only makes the fixture lane-parametric.
|
||||||
|
- RM-02 and #869 activation are out of scope.
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
- [Whole mutator-class gate](architecture/mutator-class-gate.md) — default-deny policy, revoke-first/promote-last state machine, TTL, runtime adapters, and T-B/T-C assurance boundary.
|
||||||
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
- [Compaction revocation lifecycle](architecture/compaction-revocation.md) — Claude/Pi observer matrix, same-PID generation rollover, failure fencing, and the named bounded residual stale window.
|
||||||
|
|
||||||
|
## Installation and upgrades
|
||||||
|
|
||||||
|
- [Installer state machine and recovery](guides/installer-state-machine.md) — canonical P0–P9 phases, side-effect-free checks, durable journal states, rollback/remediation, and the Debian greenfield CI gate.
|
||||||
|
- [Upgrade safety and recovery](guides/upgrade-safety-and-recovery.md) — framework ownership, durable operator snapshots, verify net, and projection regeneration.
|
||||||
|
|
||||||
## CLI and skill management
|
## CLI and skill management
|
||||||
|
|
||||||
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
- [Skill registration user guide](guides/user-guide.md#claude-code-skill-registration) — register, unregister, list statuses, automatic install/update reconciliation, and Claude reload behavior.
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
# `mosaic cred grant` / `validate` caller contract v1.5
|
|
||||||
|
|
||||||
**Status:** early binding contract for MC-CRED-01 and MB-BRAIN-01. v1.3's anonymous absence classifier was withdrawn as unsound for private users. v1.4 adopted subject-credential validation without admin visibility. v1.5 separates in-scope capability from identity measurement so correctly least-privileged tokens are not widened to service the instrument. This contract may evolve before implementation merge; incompatible changes require an explicit change notice.
|
|
||||||
|
|
||||||
## Security model
|
|
||||||
|
|
||||||
- Every call carries both `--estate` and `--host`. The configured estate-to-host mapping must match exactly. Host inference, host-adjacent fallback, and cross-estate resolution are forbidden.
|
|
||||||
- `<identity>` is always explicit. The CLI never substitutes a pane, roster, login, Unix user, or other plausible ambient identity.
|
|
||||||
- The identity token and the host-bound Tea login are one provisioning unit. Minting authority reads the principal back when the invariant is created and records that binding with the token registration. Runtime validation re-measures identity only when the token already holds `read:user`; it never widens scopes to make the instrument green.
|
|
||||||
- Grant authority is broker/delegated-provisioner material. It is never supplied as a CLI value, environment value, or bearer token readable by the requesting agent. The broker obtains it from its protected runtime credential channel.
|
|
||||||
- Commands never print token, password, authorization header, fingerprint, partial secret, or secret-bearing error text. Structured evidence contains provider object fields and endpoint metadata only.
|
|
||||||
- Every operation opens and fsyncs a durable journal before the first mutation. Journal/audit write failure is fatal. A grant is successful only after provider read-back and a sealed journal.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```text
|
|
||||||
mosaic cred grant <identity> \
|
|
||||||
--estate <estate> \
|
|
||||||
--host <host> \
|
|
||||||
--repo <owner/repo> \
|
|
||||||
--permission <read|write|admin> \
|
|
||||||
[--via <collaborator|team>] \
|
|
||||||
[--team <team>] \
|
|
||||||
[--read-only-control <identity>] \
|
|
||||||
[--json]
|
|
||||||
|
|
||||||
mosaic cred validate <identity> \
|
|
||||||
--estate <estate> \
|
|
||||||
--host <host> \
|
|
||||||
[--repo <owner/repo>] \
|
|
||||||
[--require <read|write|admin>] \
|
|
||||||
[--read-only-control <identity>] \
|
|
||||||
[--json]
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- `--via collaborator` is the default. It grants a direct repository permission and still reports the organization-membership layer.
|
|
||||||
- `--via team` requires `--team`; `--team` with collaborator mode is invalid.
|
|
||||||
- `validate --repo` reports two independent axes: capability from an in-scope repository probe, and identity binding from `/user` only when authorized. Capability may be `confirmed` while identity is `not-measured`; NOT-MEASURED is neither pass nor failure.
|
|
||||||
- Write validation requires a distinct known-read-only control identity, supplied explicitly or configured in the declared estate. The control identity and its read-only permission are read back from the provider on every invocation; the configured name alone is not evidence.
|
|
||||||
- `grant` invokes the same validation after mutation. HTTP 2xx and process exit status are never acceptance evidence.
|
|
||||||
|
|
||||||
## Machine result
|
|
||||||
|
|
||||||
`--json` writes exactly one non-secret JSON object to stdout. Human diagnostics go to stderr. Callers must decide from `outcome`, never by parsing prose.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"operation": "grant",
|
|
||||||
"outcome": "ok",
|
|
||||||
"exitCode": 0,
|
|
||||||
"retryable": false,
|
|
||||||
"subject": {
|
|
||||||
"identity": "seat-name",
|
|
||||||
"estate": "estate-name",
|
|
||||||
"host": "git.example.invalid",
|
|
||||||
"repo": "owner/repo"
|
|
||||||
},
|
|
||||||
"mutation": "applied",
|
|
||||||
"reason": {
|
|
||||||
"code": "grant-verified",
|
|
||||||
"message": "Grant matched all provider read-backs."
|
|
||||||
},
|
|
||||||
"evidence": {
|
|
||||||
"providerIdentity": {
|
|
||||||
"login": "seat-name",
|
|
||||||
"endpoint": "GET /api/v1/user",
|
|
||||||
"contentType": "application/json"
|
|
||||||
},
|
|
||||||
"tokenCapabilities": {
|
|
||||||
"state": "not-measured",
|
|
||||||
"scopes": [],
|
|
||||||
"source": "runtime-not-authorized"
|
|
||||||
},
|
|
||||||
"repositoryPermission": {
|
|
||||||
"requested": "write",
|
|
||||||
"effective": "write",
|
|
||||||
"endpoint": "GET /api/v1/repos/owner/repo",
|
|
||||||
"contentType": "application/json"
|
|
||||||
},
|
|
||||||
"organizationMembership": {
|
|
||||||
"state": "present"
|
|
||||||
},
|
|
||||||
"teamMembership": {
|
|
||||||
"state": "not-applicable"
|
|
||||||
},
|
|
||||||
"writeDifferential": {
|
|
||||||
"state": "can-write",
|
|
||||||
"credentialBinding": "same-resolution",
|
|
||||||
"transportPrincipal": "seat-name",
|
|
||||||
"authenticatedReceivePack": "advertised",
|
|
||||||
"readOnlyControl": {
|
|
||||||
"identity": "read-only-control",
|
|
||||||
"providerPermission": "read",
|
|
||||||
"receivePack": "refused"
|
|
||||||
},
|
|
||||||
"unauthenticatedReceivePack": "refused",
|
|
||||||
"artifactCreated": false,
|
|
||||||
"proves": "One immutable credential resolution authenticated both the subject identity read-back and write transport; a provider-confirmed read-only principal and an unauthenticated caller were both refused.",
|
|
||||||
"doesNotProve": "A particular ref update will pass branch protection, hooks, races, or content policy."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"audit": {
|
|
||||||
"journalId": "opaque-id",
|
|
||||||
"state": "sealed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Fields may be `null` only when their enclosing evidence state explains why. Missing decision-relevant fields make the result `indeterminate`, never `ok`.
|
|
||||||
|
|
||||||
## Terminal classes
|
|
||||||
|
|
||||||
| Outcome | Exit | Meaning | Mutation guarantee | Caller action |
|
|
||||||
| --------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
||||||
| `ok` | `0` | Requested property was established from provider objects and all required layers agree. | `validate`: `none`; `grant`: `applied` and read back. | Continue. |
|
|
||||||
| `refused` | `10` | A complete, authoritative policy/access decision denied the request. Examples: estate-host mismatch, missing explicit identity, provider identity mismatch, explicit permission denial, or cross-estate subject. | `none`; refusal occurs before mutation. | Treat as a stable denial. Do not retry without changing authority/configuration. |
|
|
||||||
| `error` | `20` | The command contract or local control failed before an access verdict. Examples: invalid arguments, malformed estate registry, insecure credential path, journal cannot be opened/fsynced, or internal invariant failure. | `none` unless `mutation` explicitly says `unknown`; `unknown` is never success. | Repair the tool/configuration. Do not reinterpret as access denial. |
|
|
||||||
| `indeterminate` | `30` | The requested security property could not be evaluated completely or evidence disagreed. Examples: provider unavailable, wrong content type/shape, permission and receive-pack disagreement, missing post-grant read-back, or unknown mutation acknowledgement. Runtime scope `not-measured` remains a separately reported axis and is neither pass nor failure. | `none`, `applied`, or `unknown`, stated explicitly. Never infer. | Fail closed at the calling gate. Investigate/re-evaluate; do not label the subject refused. |
|
|
||||||
|
|
||||||
Parsing/usage errors emitted by Commander remain exit `2` and do not produce a broker verdict. Callers should treat them as integration defects, not access decisions.
|
|
||||||
|
|
||||||
## Refusal object
|
|
||||||
|
|
||||||
A refusal is intentionally recognizable without prose:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"operation": "validate",
|
|
||||||
"outcome": "refused",
|
|
||||||
"exitCode": 10,
|
|
||||||
"retryable": false,
|
|
||||||
"subject": {
|
|
||||||
"identity": "external-seat",
|
|
||||||
"estate": "homelab",
|
|
||||||
"host": "git.example.invalid",
|
|
||||||
"repo": "owner/repo"
|
|
||||||
},
|
|
||||||
"mutation": "none",
|
|
||||||
"reason": {
|
|
||||||
"code": "no-token-for-identity",
|
|
||||||
"message": "The explicit identity has no credential in the declared estate."
|
|
||||||
},
|
|
||||||
"evidence": {
|
|
||||||
"providerIdentity": null,
|
|
||||||
"tokenCapabilities": {
|
|
||||||
"state": "not-measured",
|
|
||||||
"scopes": [],
|
|
||||||
"source": "runtime-not-authorized"
|
|
||||||
},
|
|
||||||
"repositoryPermission": null,
|
|
||||||
"organizationMembership": null,
|
|
||||||
"teamMembership": null,
|
|
||||||
"writeDifferential": null
|
|
||||||
},
|
|
||||||
"audit": {
|
|
||||||
"journalId": "opaque-id",
|
|
||||||
"state": "sealed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The git credential helper and API resolver must map the same subject/estate/host failure to the same `reason.code` and terminal class. MB-BRAIN-01 may assert this parity. A caller does not need to know which resolver path was used.
|
|
||||||
|
|
||||||
## Required reason codes
|
|
||||||
|
|
||||||
Stable v1 codes:
|
|
||||||
|
|
||||||
- refusal: `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`
|
|
||||||
- error: `invalid-input`, `estate-registry-invalid`, `insecure-credential-source`, `journal-unavailable`, `internal-invariant`
|
|
||||||
- indeterminate: `provider-unavailable`, `identity-not-visible`, `identity-not-measured`, `identity-not-found`, `unexpected-content-type`, `unexpected-provider-shape`, `scope-not-evaluable`, `permission-evidence-disagrees`, `transport-principal-mismatch`, `read-only-control-invalid`, `readback-missing`, `mutation-state-unknown`, `concurrent-mutation`, `mutation-lock-unavailable`, `mutation-lock-release-failed`, `team-scope-changed-during-grant`, `wire-audit-incomplete`
|
|
||||||
|
|
||||||
`provider-unavailable` means no usable provider answer was available. `identity-not-measured` means `/user` was scope-forbidden while an in-scope repository probe confirmed the credential capability; it is `indeterminate` only for the identity axis and must not be represented as a dead credential. `identity-not-visible` and `identity-not-found` are reserved for the unimplemented external inventory capability. `credential-rejected` means the provider rejected the credential itself (Gitea 401), which is a stable `refused` outcome. A 403 on `/user` is not credential rejection when an in-scope probe succeeds.
|
|
||||||
|
|
||||||
No anonymous or visibility-unprivileged 404 is admissible evidence of absence. `identity-not-found` requires, in the same invocation: (1) the visibility credential's own `/user` object read back as the configured authority with provider-admin visibility; (2) target lookup performed with that same authority; (3) a known-present PRIVATE control returning JSON 200 with matching login and `visibility=private`; and (4) a generated absent negative control returning JSON 404 under that same authority. Missing authority or any non-discriminating control yields `identity-not-visible`, never absence. A public positive control cannot certify private subjects.
|
|
||||||
|
|
||||||
No currently implemented operation may emit `identity-not-found`: the required governed inventory capability was deliberately declined and runtime validation must not acquire standing admin visibility. For `validate`, `/user` 401 means `credential-rejected`; `/user` 403/404 triggers the in-scope capability probe and, when that succeeds, identity is `identity-not-measured`; JSON 200 with a mismatched login is a binding refusal. A future inventory operation must meet every precondition above and receive an explicit privilege decision before making `identity-not-found` reachable.
|
|
||||||
|
|
||||||
Unknown future reason codes must still carry one of the four stable `outcome` values.
|
|
||||||
|
|
||||||
## Side-effect-free write differential
|
|
||||||
|
|
||||||
For Gitea v1, `validate --repo` resolves the subject credential exactly once into an immutable in-memory credential handle. The provider `/user` read-back, authenticated repository object, and Git smart-HTTP `git-receive-pack` advertisement all consume that same handle; callers may not perform independent lookups for those steps. The command also probes a separately resolved, provider-confirmed read-only control principal and repeats the request unauthenticated.
|
|
||||||
|
|
||||||
`can-write` requires all of the following:
|
|
||||||
|
|
||||||
1. provider `/user` login obtained with the subject credential handle equals `<identity>`;
|
|
||||||
2. authenticated repository object obtained with that same handle reports write-capable permission;
|
|
||||||
3. receive-pack obtained with that same handle returns the exact advertisement content type and protocol preamble;
|
|
||||||
4. the transport evidence records the same declared principal as the identity read-back; any handle/principal seam disagreement is `transport-principal-mismatch` and therefore `indeterminate`, never refused;
|
|
||||||
5. a distinct known-read-only credential resolves to its declared control identity, its provider repository object reports no write permission, and receive-pack is refused;
|
|
||||||
6. the unauthenticated control is refused and does not return a receive-pack advertisement;
|
|
||||||
7. estate, host, and repository in every request equal the declared subject.
|
|
||||||
|
|
||||||
The read-only control varies the mechanism under accusation: principal selection. The unauthenticated arm remains as a separate control proving authentication is required; it cannot establish which principal authenticated the subject probe. A missing, write-capable, identity-mismatched, or otherwise invalid read-only control makes the result `indeterminate`.
|
|
||||||
|
|
||||||
No ref is updated and no repository artifact is created. This proves that the declared subject credential—not merely some authenticated credential—can enter the write transport for that repository, while a provider-confirmed read-only principal and an unauthenticated caller cannot. It does not prove any specific branch update would survive branch protection, hooks, concurrent changes, or content policy.
|
|
||||||
|
|
||||||
## Grant read-back
|
|
||||||
|
|
||||||
A collaborator grant is accepted only when the provider returns the named collaborator permission and the subject credential independently reads the repository with matching effective permission. A team grant uses a same-UID-replaceable advisory lock to serialize cooperating `mosaic cred` mutations per provider team; this is optimistic coordination, not an authorization boundary or atomic CAS against direct filesystem mutation. Provider authority remains the authorization boundary. While holding that cooperative lock, the grant enumerates the team's complete repository attachment set both before and after mutation. It refuses before mutation when the team is already attached outside the one explicitly requested repository (`team-scope-exceeds-request`). If the post-mutation set is not exactly the requested repository, it returns `indeterminate` (`team-scope-changed-during-grant`) and compensates only state proven absent before the locked invocation: a newly introduced subject membership and/or requested repository attachment. Both compensations require provider absence read-back and are journaled; this is explicit all-or-verified-compensation behavior, not a cross-system atomic commit, and the operation never reports success from the stale pre-check. The grant then requires provider read-back of organization membership, team membership, team repository attachment, and effective subject permission. Token capability, repository permission, and organization/team role are reported as separate layers; no layer substitutes for another.
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Installer State Machine and Recovery
|
||||||
|
|
||||||
|
The unified installer uses a transactional P0–P9 model. It may report success only after P9 reasserts every applicable committed postcondition. Internal phases invoke the CLI by P3's absolute path; shell discovery is checked only at P8.
|
||||||
|
|
||||||
|
## Canonical phases
|
||||||
|
|
||||||
|
| Phase | Responsibility | Failure disposition |
|
||||||
|
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| P0 Resolve context | Bind uid/username to the authoritative passwd HOME/shell, state privilege mode, architecture, libc, Node, and npm | Fail before mutation |
|
||||||
|
| P1 Preflight | Validate downstream tool closure (including `git` and `python3`), writable prefix, registry lane, disk/inodes, and exclusive lock | Fail before target mutation |
|
||||||
|
| P2 Acquire artifacts | Resolve exact registry versions and an immutable framework commit; record lane and SHA-256 | Discard temporary work |
|
||||||
|
| P3 Install CLI | Install at the configured absolute prefix; require exact version plus target owner/group and non-writable executable mode | Restore the prior prefix/npmrc snapshot |
|
||||||
|
| P4 Install framework + skills | Sync framework and consume a checkout-free, lane/versioned shipped-skill declaration | Restore prior framework/runtime trees |
|
||||||
|
| P5 Identity | Validate SOUL/USER content and private modes; require private credential storage and target owner/group | Restore generated identity/credential binding |
|
||||||
|
| P6 Runtime linking / activation | Evaluate activation honestly; never treat dead enforcement hooks as active readiness | Restore runtime activation files |
|
||||||
|
| P7 Services | Provision only requested services/resources after any required P5 credential commits | Stop and restore requested services/resources |
|
||||||
|
| P8 Shell discoverability | Require fresh login and non-login shells of the actual target shell to resolve P3's path | Restore shell profiles |
|
||||||
|
| P9 Verify + commit | Re-run P0–P8, commit the manifest, and seal the journal | Leave an honestly reported resumable failure or restore the pre-install snapshot |
|
||||||
|
|
||||||
|
The phase numbers are a cross-workstream contract and must not be renumbered.
|
||||||
|
|
||||||
|
## Side-effect-free check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tools/install.sh --check # stable/latest lane
|
||||||
|
bash tools/install.sh --check --next # prerelease lane
|
||||||
|
```
|
||||||
|
|
||||||
|
`--check`:
|
||||||
|
|
||||||
|
- emits exactly one `[P0]` through `[P8]` PASS/FAIL row;
|
||||||
|
- exits non-zero if any predicate fails;
|
||||||
|
- does not create the npm prefix, lock, journal, manifest, shell profile, or runtime file;
|
||||||
|
- uses temporary npm observation storage outside the target HOME and removes it before exit.
|
||||||
|
|
||||||
|
P4 currently fails as `NOT-MEASURED / UNDECLARED` until the installer publishes `~/.config/mosaic/.install-shipped-skills.json`. C1 deliberately does not select among the conflicting candidate populations; C5 owns publishing and fulfilling that declaration. Once present, the P4 predicate requires the declaration's lane/version to match the resolved install and every named skill to remain contained under `skills/<name>/SKILL.md` with matching loadable frontmatter.
|
||||||
|
|
||||||
|
## Durable journal
|
||||||
|
|
||||||
|
Each mutating run creates a private transaction directory:
|
||||||
|
|
||||||
|
```text
|
||||||
|
${XDG_STATE_HOME:-~/.local/state}/mosaic/install/
|
||||||
|
active.json
|
||||||
|
<UTC-run-id>/
|
||||||
|
journal.ndjson
|
||||||
|
journal.ndjson.sha256 # committed runs only
|
||||||
|
commands.log
|
||||||
|
snapshot/
|
||||||
|
```
|
||||||
|
|
||||||
|
Before each mutation scope is touched, `journal.ndjson` records:
|
||||||
|
|
||||||
|
- phase and path;
|
||||||
|
- whether prior state existed and where its snapshot lives;
|
||||||
|
- the reversal action;
|
||||||
|
- the captured command-output location and command status.
|
||||||
|
|
||||||
|
Journal, action-status, manifest, or command-log write/sync failure is fatal. An unrecorded mutation is not allowed. Command diagnostics are redacted before terminal output or durable logging; credential-shaped environment values, bearer values, auth tokens, and credentialed URLs are never deliberately persisted. Successful P9 runs append a seal event, write the SHA-256 sidecar, and make the journal and sidecar read-only. Required P4/P6 action failures are persisted in the manifest so a later `--check` cannot turn a failed action into a false pass.
|
||||||
|
|
||||||
|
Rollback roots must be non-overlapping, non-symlinked, target-user-owned strict descendants of canonical `HOME`; unsafe custom `MOSAIC_HOME`/`MOSAIC_PREFIX` values fail at P0. The same validation runs again immediately before recursive rollback. The OS lock is concurrency authority: if a process dies while `active.json` still says `in-progress`, a retry that acquires the free lock preserves the stale projection as `prior-active.json` and proceeds from the honestly retained partial state.
|
||||||
|
|
||||||
|
`active.json` is the current projection:
|
||||||
|
|
||||||
|
- `in-progress`: incomplete/open transaction;
|
||||||
|
- `rolled-back`: a fault restored the snapshot;
|
||||||
|
- `rollback-failed`: restoration failed or refused a replaced/unsafe target and requires manual recovery;
|
||||||
|
- `failed-resumable`: named postconditions failed and the recorded partial state remains for remediation;
|
||||||
|
- `committed`: P9 passed and the journal is sealed.
|
||||||
|
|
||||||
|
## Failure recovery
|
||||||
|
|
||||||
|
1. Read the named phase and remediation line from installer stderr.
|
||||||
|
2. Inspect `active.json`, then the referenced `journal.ndjson` and `commands.log`. Command output needed to diagnose a failure is preserved and surfaced; it is not redirected away.
|
||||||
|
3. For `rolled-back`, verify the target paths match their pre-install state before retrying.
|
||||||
|
4. For `failed-resumable`, repair the named phase owner requirement, then run `install.sh --check` before retrying the installer.
|
||||||
|
5. Do not activate the #869 enforcement hooks merely to turn P6 green. A broker-less host with those hooks is a failed P6 state.
|
||||||
|
|
||||||
|
## Greenfield CI gate
|
||||||
|
|
||||||
|
`.woodpecker/greenfield-install.yml` runs `tools/e2e-install-test.sh` from zero in Debian/glibc as a non-root uid with `env -i`. No host HOME, npm cache, credentials, or bind mount enters the target process. Checkout mode packages the complete current checkout into an archive, pins its SHA-256 through an internal fixture seam, and copies the self-contained fixture into the container; framework-installer changes in the PR are therefore exercised rather than fetched from an older remote branch.
|
||||||
|
|
||||||
|
The C1 fixture intentionally returns an attributable RED while C2–C5 remain open. CI itself remains green only when the fixture's final P0–P9 verdicts, required discriminator rows, seeded secret-canary scan, and non-zero exit match the versioned contract in `tools/fixtures/greenfield-expected-red.tsv`. The comparator validates the complete three-case schema before selecting a case: exactly one exit and P0–P9 disposition per case, pinned require/forbid populations, and no duplicate or unknown rows. Any later remediation that changes an observed verdict makes CI red until the owning lane deliberately updates that manifest:
|
||||||
|
|
||||||
|
- `git` present: P1 and strict P3 pass; P4/P5/P6/P8 fail for their own reasons; P9 refuses success.
|
||||||
|
- `git` absent: P1 fails before target mutation and the installer emits no `Done.`.
|
||||||
|
|
||||||
|
The fixture is lane-parametric:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash tools/e2e-install-test.sh --lane next --git present
|
||||||
|
bash tools/e2e-install-test.sh --lane main --git present
|
||||||
|
```
|
||||||
|
|
||||||
|
CI exercises both lane parameters as expected-RED structural checks. A separate remote-contract arm fetches the installer at the immutable CI commit, rejects failed or empty HTTP-success bodies, compares it to the reviewed `tools/install.sh.sha256`, and executes that exact fetched artifact. The P2–P8 fault matrix runs the real phase actions (including the P3 npm path, P4 framework path, and wizard path) rather than synthetic representative writes, then compares the complete target tree to its pre-install fingerprint. Delivery targets `main` under the trunk-only merge rule; `next` remains a non-merging integration lane. The linked installer issue stays open after merge and closes only after Jarvis independently validates the greenfield behavior.
|
||||||
|
|
||||||
|
## Source trust boundary
|
||||||
|
|
||||||
|
Remote installer mode requires a non-empty body and an expected SHA-256 before execution. Remote source-archive mode separately pins the resolved commit, records the archive SHA-256, limits compressed/expanded size and entry count, and rejects traversal, links, devices, and special files before extraction. These controls provide immutable run provenance and archive safety, not an independent signing root. Signed artifact metadata/provenance is explicitly deferred by the canonical greenfield PRD; C1 does not invent a signing system. The checkout and remote CI seams verify reviewed digests before executing their artifacts.
|
||||||
@@ -12,6 +12,20 @@ with no snapshot to fall back to.
|
|||||||
Protection is layered. Each layer is independent; a later layer catches what an
|
Protection is layered. Each layer is independent; a later layer catches what an
|
||||||
earlier one misses.
|
earlier one misses.
|
||||||
|
|
||||||
|
## Layer 0 — Transaction journal (install-wide recovery)
|
||||||
|
|
||||||
|
The unified installer opens a private journal under
|
||||||
|
`${XDG_STATE_HOME:-~/.local/state}/mosaic/install/` before the first target
|
||||||
|
mutation. Every mutation scope records its path, prior snapshot, and reversal
|
||||||
|
instructions before it is touched. Journal write/sync failure is fatal, and P9
|
||||||
|
seals successful journals with a SHA-256 sidecar. See
|
||||||
|
[Installer state machine and recovery](./installer-state-machine.md).
|
||||||
|
|
||||||
|
This transaction journal is distinct from the retained operator-only backup
|
||||||
|
below. The transaction journal is required for correctness and rollback;
|
||||||
|
Layer 2's durable backup remains a separately stated, fail-open recovery bonus
|
||||||
|
for a manifest bug that the normal transaction did not detect.
|
||||||
|
|
||||||
## Layer 1 — Manifest-owned sync (prevention)
|
## Layer 1 — Manifest-owned sync (prevention)
|
||||||
|
|
||||||
The single source of truth for ownership is
|
The single source of truth for ownership is
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
subject_head=3edde464b3891ad439019fcc19aad7728e4c2fb8
|
||||||
|
source=git show HEAD:tools/install-next-lane.test.sh
|
||||||
|
|
||||||
|
477 echo 'credentialed URL userinfo leaked to terminal output' >&2; exit 1
|
||||||
|
478 fi
|
||||||
|
479 [[ "$(grep -oF '[REDACTED]@' <<<"$OUTPUT" | wc -l | tr -d ' ')" -ge 5 ]] \
|
||||||
|
480 || { echo 'credentialed URL redaction controls were not all exercised' >&2; exit 1; }
|
||||||
|
481 secret_active="$TMP/secret-state/active.json"
|
||||||
|
--
|
||||||
|
525 echo 'framework nested capture leaked credential diagnostics' >&2; exit 1
|
||||||
|
526 fi
|
||||||
|
527 [[ "$(grep -oF '[REDACTED]@' "$framework_log" | wc -l | tr -d ' ')" -ge 5 ]] \
|
||||||
|
528 || { echo 'framework URL redaction controls were not exercised' >&2; exit 1; }
|
||||||
|
529
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
source=/tmp/c1-ci-next-x.log (exact failing canonical-image xtrace)
|
||||||
|
credential material is already replaced by the redactor token [REDACTED]; no live secret is reproduced
|
||||||
|
|
||||||
|
urls=https://[REDACTED]@example.com/a https://[REDACTED]@example.net/b https://[REDACTED]@example.org/c https://[REDACTED]@example.dev/d https://[REDACTED]@example.io/e
|
||||||
|
urls=https://[REDACTED]@example.com/a https://[REDACTED]@example.net/b https://[REDACTED]@example.org/c https://[REDACTED]@example.dev/d https://[REDACTED]@example.io/e
|
||||||
|
|
||||||
|
line_count=2
|
||||||
|
occurrence_count=10
|
||||||
|
observed_assertion_value=2 (from xtrace: [[ 2 -ge 5 ]])
|
||||||
|
|
||||||
|
canonical-image discriminator (same locally cached digest as failing run):
|
||||||
|
image_id=sha256:d40fb1a218b72d3dcbf8a427a5076facf2a6d958b6854e6bbd057f7264540841 repo_digests=["git.mosaicstack.dev/mosaicstack/stack/ci-base@sha256:0f1d996a6cfcc09e6dcf979ee66c872a1b0be4f1bfde852b4790f520ddd0d776"]
|
||||||
|
busybox=BusyBox v1.37.0 (2026-01-10 15:38:28 UTC)
|
||||||
|
regex_-o_single_line=5
|
||||||
|
fixed_-oF_single_line=1
|
||||||
|
fixed_-oF_two_lines=2
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
positive_control_exit=1
|
||||||
|
seeded_line=https://[MASKED-USERINFO]@example.io/e (actual synthetic userinfo intentionally omitted here)
|
||||||
|
expected_failure=credentialed URL redaction control missing for example.io
|
||||||
|
transcript_tail:
|
||||||
|
[test] --next fast path pins resolved package versions
|
||||||
|
[test] fast path failure falls back to source build
|
||||||
|
[test] source-build failure is fatal and restores the pre-install prefix
|
||||||
|
[test] corrupt source archive is fatal and restores the pre-install prefix
|
||||||
|
[test] --dev source install does not require registry version resolution
|
||||||
|
[test] explicit --ref keeps source lane and avoids @next lookup
|
||||||
|
[test] --check --next rejects mismatched prerelease pipeline suffixes
|
||||||
|
[test] full framework path receives P3 absolute CLI without relying on PATH
|
||||||
|
[test] captured diagnostics redact seeded credential canary everywhere
|
||||||
|
credentialed URL redaction control missing for example.io
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[test] --next fast path pins resolved package versions
|
||||||
|
[test] fast path failure falls back to source build
|
||||||
|
[test] source-build failure is fatal and restores the pre-install prefix
|
||||||
|
[test] corrupt source archive is fatal and restores the pre-install prefix
|
||||||
|
[test] --dev source install does not require registry version resolution
|
||||||
|
[test] explicit --ref keeps source lane and avoids @next lookup
|
||||||
|
[test] --check --next rejects mismatched prerelease pipeline suffixes
|
||||||
|
[test] full framework path receives P3 absolute CLI without relying on PATH
|
||||||
|
[test] captured diagnostics redact seeded credential canary everywhere
|
||||||
|
credentialed URL redaction control missing for example.io
|
||||||
+579
@@ -0,0 +1,579 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="/work"
|
||||||
|
# shellcheck source=tools/test-enumeration-assertions.sh
|
||||||
|
source "$ROOT/tools/test-enumeration-assertions.sh"
|
||||||
|
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
export TMPDIR="$TMP/runtime-tmp"
|
||||||
|
mkdir -p "$TMPDIR"
|
||||||
|
|
||||||
|
FAKE_BIN="$TMP/bin"
|
||||||
|
HOME_DIR="$TMP/home"
|
||||||
|
PREFIX="$HOME_DIR/prefix"
|
||||||
|
MOSAIC_HOME="$HOME_DIR/mosaic"
|
||||||
|
STATE="$TMP/state"
|
||||||
|
LOG="$TMP/npm.log"
|
||||||
|
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||||
|
|
||||||
|
# Model the supported non-root/glibc target explicitly even when this harness
|
||||||
|
# itself runs as root in Alpine/BusyBox CI.
|
||||||
|
cat > "$FAKE_BIN/id" <<'FAKE_ID'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
case "${1:-}" in
|
||||||
|
-u) echo 1001 ;;
|
||||||
|
-g) echo 1001 ;;
|
||||||
|
-un) echo fixture-user ;;
|
||||||
|
*) exec /bin/id "$@" ;;
|
||||||
|
esac
|
||||||
|
FAKE_ID
|
||||||
|
cat > "$FAKE_BIN/getent" <<FAKE_GETENT
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'fixture-user:x:1001:1001::%s:/bin/bash\n' '$HOME_DIR'
|
||||||
|
FAKE_GETENT
|
||||||
|
cat > "$FAKE_BIN/ldd" <<'FAKE_LDD'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'ldd (GNU libc) 2.36\n'
|
||||||
|
FAKE_LDD
|
||||||
|
cat > "$FAKE_BIN/stat" <<'FAKE_STAT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [[ "${1:-} ${2:-}" == '-c %u' ]]; then
|
||||||
|
[[ "${3:-}" == "${MOSAIC_TEST_WRONG_OWNER_PATH:-__none__}" ]] && echo 9999 || echo 1001
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-} ${2:-}" == '-c %g' ]]; then
|
||||||
|
[[ "${3:-}" == "${MOSAIC_TEST_WRONG_GROUP_PATH:-__none__}" ]] && echo 9999 || echo 1001
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /bin/stat "$@"
|
||||||
|
FAKE_STAT
|
||||||
|
cat > "$FAKE_BIN/realpath" <<'FAKE_REALPATH'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import os, sys
|
||||||
|
args=sys.argv[1:]
|
||||||
|
mode=args.pop(0) if args and args[0] in ('-e','-m') else '-m'
|
||||||
|
if args and args[0]=='--': args.pop(0)
|
||||||
|
if len(args)!=1 or (mode=='-e' and not os.path.exists(args[0])): raise SystemExit(1)
|
||||||
|
print(os.path.realpath(args[0]))
|
||||||
|
FAKE_REALPATH
|
||||||
|
chmod 0755 "$FAKE_BIN/id" "$FAKE_BIN/getent" "$FAKE_BIN/ldd" "$FAKE_BIN/stat" "$FAKE_BIN/realpath"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
STATE="${MOSAIC_TEST_STATE:?}"
|
||||||
|
echo "$*" >> "$LOG"
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--version" ]]; then
|
||||||
|
echo "10.6.2"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_cli() {
|
||||||
|
local version="$1"
|
||||||
|
echo "$version" > "$STATE/mosaic"
|
||||||
|
mkdir -p "${MOSAIC_PREFIX:?}/bin"
|
||||||
|
cat > "$MOSAIC_PREFIX/bin/mosaic" <<CLI
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "\${1:-}" == "wizard" ]]; then
|
||||||
|
printf 'wizard\n' >> "\${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
mkdir -p "\${MOSAIC_HOME:?}" "\${HOME:?}/.config/mosaic-gateway"
|
||||||
|
printf '# Soul\\n\\nConfigured.\\n' > "\$MOSAIC_HOME/SOUL.md"
|
||||||
|
printf '# User\\n\\nConfigured.\\n' > "\$MOSAIC_HOME/USER.md"
|
||||||
|
chmod 0600 "\$MOSAIC_HOME/SOUL.md" "\$MOSAIC_HOME/USER.md"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s\\n' '$version'
|
||||||
|
CLI
|
||||||
|
chmod +x "$MOSAIC_PREFIX/bin/mosaic"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$1" == "view" ]]; then
|
||||||
|
if [[ "${MOSAIC_TEST_FAIL_NPM_VIEW:-0}" == "1" ]]; then
|
||||||
|
echo "forced registry metadata failure" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$2 $3" in
|
||||||
|
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
||||||
|
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
||||||
|
"@mosaicstack/mosaic version") echo "0.0.48" ;;
|
||||||
|
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$1" == "install" ]]; then
|
||||||
|
if [[ -n "${MOSAIC_INSTALL_SECRET_CANARY:-}" ]]; then
|
||||||
|
printf 'registry diagnostic authToken=%s\n' "$MOSAIC_INSTALL_SECRET_CANARY"
|
||||||
|
printf 'urls=https://alice:p@[email protected]/a https://bob:pa:[email protected]/b https://carol:p%%[email protected]/c https://[email protected]/d https://public.example/e\n'
|
||||||
|
printf 'Authorization: Basic QWxhZGRpbjpvcGVu\n//registry/:_auth=Ym9iOnNlY3JldA==\nCookie: session=abc123\nSet-Cookie: sid=xyz789\n'
|
||||||
|
printf '%s\n' "$MOSAIC_INSTALL_SECRET_CANARY" > "${MOSAIC_TEST_CANARY_OBSERVATION:?}"
|
||||||
|
fi
|
||||||
|
case "$*" in
|
||||||
|
*"@mosaicstack/[email protected]"*)
|
||||||
|
install_cli "0.0.49-next.999"
|
||||||
|
;;
|
||||||
|
*"@mosaicstack/[email protected]"*)
|
||||||
|
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
||||||
|
echo "forced gateway install failure" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "0.0.7-next.999" > "$STATE/gateway"
|
||||||
|
;;
|
||||||
|
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
||||||
|
install_cli "0.0.0-source"
|
||||||
|
;;
|
||||||
|
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
||||||
|
echo "0.0.0-source" > "$STATE/gateway"
|
||||||
|
;;
|
||||||
|
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$1" == "ls" ]]; then
|
||||||
|
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
|
||||||
|
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
|
||||||
|
node -e '
|
||||||
|
const cli = process.argv[1];
|
||||||
|
const gateway = process.argv[2];
|
||||||
|
const dependencies = {};
|
||||||
|
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
|
||||||
|
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
|
||||||
|
process.stdout.write(JSON.stringify({ dependencies }));
|
||||||
|
' "$cli" "$gateway"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "unexpected npm command: $*" >&2
|
||||||
|
exit 1
|
||||||
|
FAKE_NPM
|
||||||
|
chmod +x "$FAKE_BIN/npm"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
headers=""; output=""; url=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-D) headers="$2"; shift 2 ;;
|
||||||
|
-o) output="$2"; shift 2 ;;
|
||||||
|
--max-filesize) shift 2 ;;
|
||||||
|
-*) shift ;;
|
||||||
|
*) url="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
case "$url" in
|
||||||
|
*/api/v1/repos/mosaicstack/stack/commits?sha=*)
|
||||||
|
printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers"
|
||||||
|
printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output"
|
||||||
|
;;
|
||||||
|
*/archive/*.tar.gz)
|
||||||
|
if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then
|
||||||
|
printf 'not-a-tarball\n' > "$output"
|
||||||
|
else
|
||||||
|
archive_root="$(mktemp -d)"
|
||||||
|
mkdir -p "$archive_root/stack"
|
||||||
|
printf 'fixture\n' > "$archive_root/stack/.fixture"
|
||||||
|
/bin/tar czf "$output" -C "$archive_root" stack
|
||||||
|
rm -rf "$archive_root"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
FAKE_CURL
|
||||||
|
chmod +x "$FAKE_BIN/curl"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
dest=""; list=false
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-C) dest="$2"; shift 2 ;;
|
||||||
|
-*t*|t*) list=true; shift ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ "$list" == true ]] && exit 0
|
||||||
|
if [[ -z "$dest" ]]; then
|
||||||
|
echo "fake tar missing -C destination" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$dest/stack/packages/mosaic/framework" "$dest/stack/apps/gateway"
|
||||||
|
cat > "$dest/stack/packages/mosaic/framework/install.sh" <<'FRAMEWORK'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
expected="${MOSAIC_PREFIX:?}/bin/mosaic"
|
||||||
|
[[ "${MOSAIC_CLI_PATH:-}" == "$expected" && -x "$MOSAIC_CLI_PATH" ]] || {
|
||||||
|
echo "framework did not receive P3 absolute CLI (got=${MOSAIC_CLI_PATH:-unset} expected=$expected)" >&2
|
||||||
|
exit 61
|
||||||
|
}
|
||||||
|
printf 'framework-cli=%s version=%s\n' "$MOSAIC_CLI_PATH" "$($MOSAIC_CLI_PATH --version)" >> "${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
mkdir -p "${MOSAIC_HOME:?}/credentials"
|
||||||
|
chmod 0700 "$MOSAIC_HOME/credentials"
|
||||||
|
printf '# framework fixture\n' > "$MOSAIC_HOME/AGENTS.md"
|
||||||
|
FRAMEWORK
|
||||||
|
chmod 0755 "$dest/stack/packages/mosaic/framework/install.sh"
|
||||||
|
FAKE_TAR
|
||||||
|
chmod +x "$FAKE_BIN/tar"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
echo "pnpm $*" >> "$LOG"
|
||||||
|
|
||||||
|
if [[ "$1" == "pack" ]]; then
|
||||||
|
out=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--pack-destination) out="$2"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [[ -z "$out" ]]; then
|
||||||
|
echo "fake pnpm pack missing destination" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$out"
|
||||||
|
case "$PWD" in
|
||||||
|
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
|
||||||
|
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
|
||||||
|
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then
|
||||||
|
echo "forced pnpm install failure" >&2
|
||||||
|
exit 42
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Other install/build commands are no-ops in this harness.
|
||||||
|
exit 0
|
||||||
|
FAKE_PNPM
|
||||||
|
chmod +x "$FAKE_BIN/pnpm"
|
||||||
|
|
||||||
|
reset_state() {
|
||||||
|
: > "$LOG"
|
||||||
|
rm -f "$STATE"/*
|
||||||
|
}
|
||||||
|
|
||||||
|
tree_fingerprint() {
|
||||||
|
local root="$1"
|
||||||
|
if [[ ! -d "$root" ]]; then printf 'ABSENT\n'; return; fi
|
||||||
|
python3 - "$root" <<'PY'
|
||||||
|
import hashlib, os, stat, sys
|
||||||
|
root=os.path.abspath(sys.argv[1]); rows=[]
|
||||||
|
for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
|
||||||
|
for name in dirs + files:
|
||||||
|
path=os.path.join(current,name); meta=os.lstat(path)
|
||||||
|
rel=os.path.relpath(path,root)
|
||||||
|
target=os.readlink(path) if stat.S_ISLNK(meta.st_mode) else ''
|
||||||
|
digest=''
|
||||||
|
if stat.S_ISREG(meta.st_mode):
|
||||||
|
with open(path,'rb') as handle: digest=hashlib.sha256(handle.read()).hexdigest()
|
||||||
|
rows.append((rel,stat.S_IFMT(meta.st_mode),stat.S_IMODE(meta.st_mode),target,digest))
|
||||||
|
payload='\n'.join('|'.join(map(str,row)) for row in sorted(rows)).encode()
|
||||||
|
print(hashlib.sha256(payload).hexdigest())
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix_fingerprint() { tree_fingerprint "$PREFIX"; }
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] --next fast path pins resolved package versions"
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||||
|
)"
|
||||||
|
|
||||||
|
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
|
||||||
|
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||||
|
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||||
|
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
|
||||||
|
echo "expected exact-version installs, found mutable @next install" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then
|
||||||
|
echo "fast path unexpectedly fell back to source" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]]
|
||||||
|
JOURNAL="$(node -p "require('$ACTIVE').journal")"
|
||||||
|
[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]]
|
||||||
|
( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null )
|
||||||
|
grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL"
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] fast path failure falls back to source build"
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||||
|
)"
|
||||||
|
|
||||||
|
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||||
|
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||||
|
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
||||||
|
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
|
||||||
|
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
||||||
|
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] source-build failure is fatal and restores the pre-install prefix"
|
||||||
|
before_prefix="$(prefix_fingerprint)"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FAIL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||||
|
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||||
|
grep -qF 'forced pnpm install failure' <<<"$OUTPUT"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] corrupt source archive is fatal and restores the pre-install prefix"
|
||||||
|
before_prefix="$(prefix_fingerprint)"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
MOSAIC_TEST_CORRUPT_ARCHIVE=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FAIL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||||
|
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||||
|
grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] --dev source install does not require registry version resolution"
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NPM_VIEW=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch
|
||||||
|
)"
|
||||||
|
grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||||
|
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
||||||
|
)"
|
||||||
|
CHECK_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||||
|
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||||
|
if grep -qF '@next version' "$LOG"; then
|
||||||
|
echo "explicit ref should not query @next dist-tags" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] --check --next rejects mismatched prerelease pipeline suffixes"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --cli --next
|
||||||
|
)"
|
||||||
|
CHECK_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||||
|
grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT"
|
||||||
|
|
||||||
|
printf '[test] full framework path receives P3 absolute CLI without relying on PATH\n'
|
||||||
|
rm -rf "$HOME_DIR" "$STATE"; mkdir -p "$HOME_DIR" "$STATE"; reset_state
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/full-state" MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FULL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FULL_STATUS" -ne 0 ]] # P4 remains intentionally undeclared until C5.
|
||||||
|
grep -qF "framework-cli=$PREFIX/bin/mosaic version=0.0.49-next.999" "$LOG"
|
||||||
|
if grep -q "CLI not found on PATH\|did not receive P3 absolute CLI" <<<"$OUTPUT"; then
|
||||||
|
echo "internal framework phase depended on PATH instead of P3 absolute CLI" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[test] captured diagnostics redact seeded credential canary everywhere\n'
|
||||||
|
rm -rf "$HOME_DIR" "$STATE"; mkdir -p "$HOME_DIR" "$STATE"; reset_state
|
||||||
|
canary='C1_SECRET_CANARY_7df4c2'
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/secret-state" MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_INSTALL_SECRET_CANARY="$canary" MOSAIC_TEST_CANARY_OBSERVATION="$TMP/canary-observed" \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
# Positive control: replace the removed redacted example.io source with one deliberately unredacted userinfo URL.
|
||||||
|
OUTPUT+=$'\nhttps://[email protected]/e'
|
||||||
|
if grep -qF "$canary" <<<"$OUTPUT"; then echo 'credential canary leaked to terminal output' >&2; exit 1; fi
|
||||||
|
if grep -Eq 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' <<<"$OUTPUT"; then
|
||||||
|
echo 'credentialed URL userinfo leaked to terminal output' >&2; exit 1
|
||||||
|
fi
|
||||||
|
for host in example.com example.net example.org example.dev example.io; do
|
||||||
|
grep -qF "https://[REDACTED]@$host" <<<"$OUTPUT" \
|
||||||
|
|| { echo "credentialed URL redaction control missing for $host" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
secret_active="$TMP/secret-state/active.json"
|
||||||
|
secret_journal="$(node -p "require('$secret_active').journal")"
|
||||||
|
secret_command_log="$(dirname "$secret_journal")/commands.log"
|
||||||
|
if grep -R -F "$canary" "$secret_command_log" "$HOME_DIR" 2>/dev/null; then
|
||||||
|
echo 'credential canary leaked to persistent installer output' >&2; exit 1
|
||||||
|
fi
|
||||||
|
if grep -E 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' "$secret_command_log" >/dev/null; then
|
||||||
|
echo 'credentialed URL userinfo leaked to persistent installer output' >&2; exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$(cat "$TMP/canary-observed" 2>/dev/null || true)" != "$canary" ]]; then
|
||||||
|
echo 'credential canary positive control was not exercised' >&2; exit 1
|
||||||
|
fi
|
||||||
|
test_assert_find_empty 'redacted diagnostic staging files' \
|
||||||
|
"$TMPDIR" -maxdepth 1 -type f \
|
||||||
|
\( -name 'mosaic-phase-redacted.*' -o -name 'mosaic-post-redacted.*' \) || exit 1
|
||||||
|
|
||||||
|
printf '[test] framework nested capture redacts the same canary and URL variants\n'
|
||||||
|
framework_test_home="$TMP/framework-redact-home"
|
||||||
|
framework_target="$framework_test_home/.config/mosaic"
|
||||||
|
framework_cli="$TMP/framework-redact-cli"
|
||||||
|
framework_log="$TMP/framework-redact-commands.log"
|
||||||
|
framework_status="$TMP/framework-redact-status.tsv"
|
||||||
|
mkdir -p "$framework_test_home"; : > "$framework_log"; : > "$framework_status"
|
||||||
|
cat > "$framework_cli" <<'FRAMEWORK_CLI'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'nested authToken=%s\n' "${MOSAIC_INSTALL_SECRET_CANARY:?}"
|
||||||
|
printf 'nested=https://alice:p@[email protected]/a https://bob:pa:[email protected]/b https://carol:p%%[email protected]/c https://[email protected]/d https://user%%[email protected]/e\n'
|
||||||
|
printf 'Authorization: Basic QWxhZGRpbjpvcGVu\n//registry/:_auth=Ym9iOnNlY3JldA==\nCookie: session=abc123\nSet-Cookie: sid=xyz789\n'
|
||||||
|
exit 1
|
||||||
|
FRAMEWORK_CLI
|
||||||
|
chmod 0755 "$framework_cli"
|
||||||
|
set +e
|
||||||
|
FRAMEWORK_OUTPUT="$(
|
||||||
|
HOME="$framework_test_home" MOSAIC_HOME="$framework_target" MOSAIC_INSTALL_MODE=overwrite \
|
||||||
|
MOSAIC_CLI_PATH="$framework_cli" MOSAIC_INSTALL_SECRET_CANARY="$canary" \
|
||||||
|
MOSAIC_INSTALL_COMMAND_LOG="$framework_log" MOSAIC_INSTALL_PHASE_STATUS_FILE="$framework_status" \
|
||||||
|
MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 MOSAIC_SKIP_SKILLS_SYNC=1 \
|
||||||
|
bash "$ROOT/packages/mosaic/framework/install.sh" 2>&1
|
||||||
|
)"
|
||||||
|
framework_install_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$framework_install_status" -eq 0 ]]
|
||||||
|
if grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" <<<"$FRAMEWORK_OUTPUT" \
|
||||||
|
|| grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" "$framework_log"; then
|
||||||
|
echo 'framework nested capture leaked credential diagnostics' >&2; exit 1
|
||||||
|
fi
|
||||||
|
for host in example.com example.net example.org example.dev example.io; do
|
||||||
|
grep -qF "https://[REDACTED]@$host" "$framework_log" \
|
||||||
|
|| { echo "framework URL redaction control missing for $host" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '[test] real P2-P8 actions run under fault injection and restore actual surfaces\n'
|
||||||
|
for phase in P2 P3 P4 P5 P6 P7 P8; do
|
||||||
|
rm -rf "$HOME_DIR" "$STATE" "$TMP/fault-$phase"; mkdir -p "$HOME_DIR" "$STATE" "$TMP/fault-$phase"
|
||||||
|
printf 'operator-sentinel\n' > "$HOME_DIR/operator.txt"
|
||||||
|
reset_state
|
||||||
|
before="$(tree_fingerprint "$HOME_DIR")"
|
||||||
|
set +e
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/fault-$phase" MOSAIC_INSTALL_FAULT_AFTER="$phase" \
|
||||||
|
MOSAIC_INSTALL_SELF_TEST_ALLOW=1 MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --state-machine-self-test --next --yes \
|
||||||
|
>"$TMP/fault-$phase.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$status" -eq 97 ]] || { echo "$phase real fault expected 97, got $status" >&2; exit 1; }
|
||||||
|
[[ -s "$LOG" ]] || { echo "$phase fault never entered the real action path" >&2; exit 1; }
|
||||||
|
[[ "$(tree_fingerprint "$HOME_DIR")" == "$before" ]] || { echo "$phase real rollback mismatch" >&2; exit 1; }
|
||||||
|
grep -q "phase=$phase" "$TMP/fault-$phase.log"
|
||||||
|
test_assert_no_file_content_match "$phase fault-state" \
|
||||||
|
'"status"[[:space:]]*:[[:space:]]*"in-progress"' "$TMP/fault-$phase" || exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '[test] stale projection is preserved while the real fault path acquires a free OS lock\n'
|
||||||
|
rm -rf "$HOME_DIR" "$STATE" "$TMP/stale-state"; mkdir -p "$HOME_DIR" "$STATE" "$TMP/stale-state"
|
||||||
|
printf '{"status":"in-progress","journal":"%s"}\n' "$TMP/stale-state/dead-run/journal.ndjson" > "$TMP/stale-state/active.json"
|
||||||
|
reset_state
|
||||||
|
set +e
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/stale-state" MOSAIC_INSTALL_FAULT_AFTER=P2 \
|
||||||
|
MOSAIC_INSTALL_SELF_TEST_ALLOW=1 MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --state-machine-self-test --next --yes >"$TMP/stale.log" 2>&1
|
||||||
|
stale_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$stale_status" -eq 97 ]]
|
||||||
|
find "$TMP/stale-state" -name prior-active.json -type f -print -quit | grep -q .
|
||||||
|
[[ "$(node -p "require('$TMP/stale-state/active.json').status")" == rolled-back ]]
|
||||||
|
|
||||||
|
echo "[test] installer next lane tests passed"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[test] --next fast path pins resolved package versions
|
||||||
|
[test] fast path failure falls back to source build
|
||||||
|
[test] source-build failure is fatal and restores the pre-install prefix
|
||||||
|
[test] corrupt source archive is fatal and restores the pre-install prefix
|
||||||
|
[test] source archive with multiple extracted roots fails instead of selecting by find order
|
||||||
|
[test] --dev source install does not require registry version resolution
|
||||||
|
[test] explicit --ref keeps source lane and avoids @next lookup
|
||||||
|
[test] --check --next rejects mismatched prerelease pipeline suffixes
|
||||||
|
[test] full framework path receives P3 absolute CLI without relying on PATH
|
||||||
|
[test] captured diagnostics redact seeded credential canary everywhere
|
||||||
|
[test] framework nested capture redacts the same canary and URL variants
|
||||||
|
[test] real P2-P8 actions run under fault injection and restore actual surfaces
|
||||||
|
P2 left an in-progress transaction
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[test] --next fast path pins resolved package versions
|
||||||
|
[test] fast path failure falls back to source build
|
||||||
|
[test] source-build failure is fatal and restores the pre-install prefix
|
||||||
|
[test] corrupt source archive is fatal and restores the pre-install prefix
|
||||||
|
[test] source archive with multiple extracted roots fails instead of selecting by find order
|
||||||
|
[test] --dev source install does not require registry version resolution
|
||||||
|
[test] explicit --ref keeps source lane and avoids @next lookup
|
||||||
|
[test] --check --next rejects mismatched prerelease pipeline suffixes
|
||||||
|
[test] full framework path receives P3 absolute CLI without relying on PATH
|
||||||
|
[test] captured diagnostics redact seeded credential canary everywhere
|
||||||
|
[test] framework nested capture redacts the same canary and URL variants
|
||||||
|
[test] real P2-P8 actions run under fault injection and restore actual surfaces
|
||||||
|
[test] stale projection is preserved while the real fault path acquires a free OS lock
|
||||||
|
[test] installer next lane tests passed
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[test] enumeration failure cannot mask a planted in-progress transaction
|
||||||
|
[test] FAIL: planted in-progress transaction plus failed enumeration passed the full suite
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[test] --next fast path pins resolved package versions
|
||||||
|
[test] fast path failure falls back to source build
|
||||||
|
[test] source-build failure is fatal and restores the pre-install prefix
|
||||||
|
[test] corrupt source archive is fatal and restores the pre-install prefix
|
||||||
|
[test] source archive with multiple extracted roots fails instead of selecting by find order
|
||||||
|
[test] --dev source install does not require registry version resolution
|
||||||
|
[test] explicit --ref keeps source lane and avoids @next lookup
|
||||||
|
[test] --check --next rejects mismatched prerelease pipeline suffixes
|
||||||
|
[test] full framework path receives P3 absolute CLI without relying on PATH
|
||||||
|
[test] captured diagnostics redact seeded credential canary everywhere
|
||||||
|
[test] framework nested capture redacts the same canary and URL variants
|
||||||
|
[test] real P2-P8 actions run under fault injection and restore actual surfaces
|
||||||
|
find: ‘/tmp/mosaic-next-install-test-hqL7U0/fault-P2/blocked’: Permission denied
|
||||||
|
[test] ERROR: P2 fault-state enumeration failed
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#1050 C1 ROUND 3 — REMOTE-ARM DOWNSTREAM SOURCE BINDING
|
||||||
|
|
||||||
|
IDENTITY / SCOPE
|
||||||
|
- branch: feat/1050-install-state-machine-red-fixture
|
||||||
|
- predecessor/provider lease pin: ff3f0d29f1763bed44a60610d073036112e66b77
|
||||||
|
- status language: believed-fixed, pending jarvis validation
|
||||||
|
- in scope: remote arm's mosaicstack/stack framework/source archive (#7)
|
||||||
|
- unchanged/fenced: expected-RED manifest including P6=FAIL; #869; #1068 sequencing
|
||||||
|
- corrected reference citation: the container-image acquisition path in fa-install.sh only
|
||||||
|
|
||||||
|
ACQUISITION-PATH CENSUS (10/10 CODE-READ; COMPLETE RUNTIME PATH CENSUS NOT MEASURED)
|
||||||
|
1. Woodpecker checkout at CI commit — fully constrained by intended CI identity.
|
||||||
|
2. node:22-bookworm-slim image — unpinned sibling, out of scope.
|
||||||
|
3. apt package set — unpinned sibling, out of scope.
|
||||||
|
4. tools/install.sh at CI_COMMIT_SHA + verified SHA-256 — fully constrained.
|
||||||
|
5. @mosaicstack/mosaic@next — P2 CLI value is authoritative; realised disagreement is detected at P3 — fully constrained at the top-level package version.
|
||||||
|
6. @mosaicstack/gateway@next — later re-resolution wins and is verified against itself; drift from the first value can be silently accepted — partially constrained TOCTOU sibling, out of scope.
|
||||||
|
7. stack framework/source archive — pre-fix internally pinned to WRONG identity 4df478cdd150fdf8d52ea109f02ade5d85017acd; post-fix bound to intended CI commit.
|
||||||
|
8. unversioned sequential-thinking npx package — unpinned sibling, out of scope.
|
||||||
|
9. Excalidraw dependency graph — shipped lockfile exact versions + registry integrity hashes — fully constrained by lock/integrity.
|
||||||
|
10. agent-skills default-branch clone — unpinned sibling, out of scope.
|
||||||
|
|
||||||
|
Post-fix mechanism breakdown: fully constrained 5/10 (#1,#4,#5,#7,#9); partially constrained 1/10 (#6); unpinned 4/10 (#2,#3,#8,#10). Same-CI stack payload hops moved from 1/2 to 2/2. The fix does not increase the number of constrained paths; it corrects #7 from wrong identity to intended identity. This census is a LOWER BOUND on defects and an UPPER BOUND on assurance: questioning revised the claimed assurance downward each time (blended 6/10 -> mechanism split -> 5 full + 1 partial + 4 unpinned), never upward.
|
||||||
|
|
||||||
|
#5/#6 RESOLUTION-FAILURE / TOCTOU DISPOSITION (CODE-READ; DISAGREEMENT INJECTION NOT MEASURED)
|
||||||
|
- P1 metadata failure blocks before mutation.
|
||||||
|
- Failed package install falls back to the one immutable stack archive resolved in P2; source resolution/fetch failure is fatal.
|
||||||
|
- #5 CLI: P2 RESOLVED_CLI_VERSION is authoritative; a later installed CLI mismatch is detected by P3 and rolls back.
|
||||||
|
- #6 gateway: no authoritative P2 gateway value is persisted. The last resolution in install_next_cli_from_registry wins and is compared with itself, so successful same-suffix gateway drift can be silently accepted. Reported, not fixed.
|
||||||
|
|
||||||
|
RED-FIRST — STALE NEXT REJECTED BEFORE ACQUISITION FIX
|
||||||
|
Command shape: exact ff3f0d2 installer URL/digest + expected source commit/digest, remote next lane.
|
||||||
|
- expected source commit: ff3f0d29f1763bed44a60610d073036112e66b77
|
||||||
|
- expected source SHA-256: 944c6db1b01b23c83169e6dc83e0d31262b1d24a2825270441745eb65c269c25
|
||||||
|
- fixture rc: 1
|
||||||
|
- realised source commit: 4df478cdd150fdf8d52ea109f02ade5d85017acd
|
||||||
|
- realised source SHA-256: 3e6d831efe13c3b2c0501507099d4a566af5abf877dacf85e5d7e4284d35e5c7
|
||||||
|
- source control: FAIL (realised != expected)
|
||||||
|
- P6 final fixture value: PASS (false pass from stale framework)
|
||||||
|
- forbidden evidence: CLI not found on PATH present
|
||||||
|
- comparator rc: 1; failures=8/checks=19
|
||||||
|
|
||||||
|
IMPLEMENTED PATH #7
|
||||||
|
- .woodpecker/greenfield-install.yml supplies MOSAIC_FIXTURE_SOURCE_COMMIT=${CI_COMMIT_SHA} while still invoking --lane next and --source remote.
|
||||||
|
- tools/e2e-install-test.sh validates 40-hex commit input, derives the exact commit archive URL, rejects failed/empty fetch, computes SHA-256 over that exact body, and passes the body/commit/digest through MOSAIC_INSTALL_LOCAL_SOURCE_{ARCHIVE,COMMIT,SHA256}.
|
||||||
|
- The existing installer local-source seam re-hashes the body and rejects mismatch.
|
||||||
|
- The fixture verifies realised .install-manifest.json sourceCommit and sourceSha256 against the exact fetched body after installation. Mismatch exits before the phase comparator; there is no fallback to next.
|
||||||
|
|
||||||
|
EXACT-SOURCE REPRODUCIBILITY RESULT — N=5 IDENTICAL FINAL-TREE EXECUTIONS
|
||||||
|
Invariant inputs on every run:
|
||||||
|
- fixture mode: --lane next --source remote --git present
|
||||||
|
- installer/source commit: ff3f0d29f1763bed44a60610d073036112e66b77
|
||||||
|
- installer SHA-256: e59cb441a2f37ae9150f8eae470238e9d858a1816df93343d9784a6796676096
|
||||||
|
- realised source SHA-256: 944c6db1b01b23c83169e6dc83e0d31262b1d24a2825270441745eb65c269c25
|
||||||
|
- resolved/realised CLI: @mosaicstack/mosaic@next = 0.0.50-next.2207
|
||||||
|
- fixture rc=1, P3 PASS, SOURCE-CONTROL PASS, installer_exit=1, done_claims=0, P6 FAIL, P9 FAIL
|
||||||
|
|
||||||
|
Per-run outcomes:
|
||||||
|
run 1: comparator rc=0; checks=19/19; dead_hooks=0; P6 reason=runtime linking/activation action reported a required failure; elapsed/load NOT MEASURED
|
||||||
|
run 2: comparator rc=0; checks=19/19; dead_hooks=0; P6 reason=runtime linking/activation action reported a required failure; elapsed/load NOT MEASURED
|
||||||
|
run 3: comparator rc=1; checks=18/19; dead_hooks=2; P6 reason=broker absent but dead enforcement hooks are active (count=2); elapsed=912s; load before=7.79 7.51 8.93; load after=12.28 14.82 11.79
|
||||||
|
run 4: comparator rc=0; checks=19/19; dead_hooks=0; P6 reason=runtime linking/activation action reported a required failure; elapsed=938s; load before=12.28 14.82 11.79; load after=9.84 15.18 13.83
|
||||||
|
run 5: comparator rc=0; checks=19/19; dead_hooks=0; P6 reason=runtime linking/activation action reported a required failure; elapsed=954s; load before=9.84 15.18 13.83; load after=3.94 4.76 8.18
|
||||||
|
|
||||||
|
Rate, not verdict:
|
||||||
|
- comparator rc=0: 4/5
|
||||||
|
- comparator rc=1: 1/5
|
||||||
|
- action-failure P6 reason: 4/5
|
||||||
|
- dead-hooks-active count=2 P6 reason: 1/5
|
||||||
|
- source commit/digest property: PASS 5/5
|
||||||
|
- P6 outcome property: FAIL 5/5
|
||||||
|
No comparator verdict is claimed. Identical source, digest, and package version produced different comparator values; this gate is nondeterministic on the #869 probe path. Load does not explain the small sample monotonically: run 4 had the highest observed 5/15-minute load but produced rc=0. The 2000ms capability-probe timeout remains a code-read hypothesis, NOT MEASURED as causal.
|
||||||
|
|
||||||
|
CHECKOUT CONTROL (EXECUTED, NOT PART OF REMOTE N=5)
|
||||||
|
The checkout-source arm independently produced: fixture rc=1; @next CLI 0.0.50-next.2207; P3 PASS; P6 FAIL with dead hooks active count=2; comparator rc=1 with the same 1/19 required-reason miss. This showed that outcome can occur independently of the remote #7 binding; its reproducibility was not separately measured.
|
||||||
|
|
||||||
|
PROPERTY / SIGNAL FINDING (MANIFEST UNCHANGED)
|
||||||
|
P6=FAIL remains the correct property and is unchanged. The comparator additionally pins one reason signal: "runtime linking/activation action reported a required failure". Exact-source P6 failed on all 5/5 runs, while the reason signal varied. The reason assertion was not widened to fit either observation.
|
||||||
|
|
||||||
|
#869 FINDING (BEHAVIOR EXECUTED; ROOT-CAUSE ATTRIBUTION CODE-READ)
|
||||||
|
Current @next now carries the lease capability. defaultSupervisorProbe treats bundled launcher+daemon file presence plus a nonempty resolved socket PATH as supervisor presence; it does not require the socket itself to exist. One of five identical clean broker-absent runs wired two dead hooks and let the linker exit zero; four reported the action failure. The 2000ms defaultCapabilityProbe subprocess timeout is a code-read candidate for the variance, NOT MEASURED as causal. Fixing this is #869 scope and was not performed.
|
||||||
|
|
||||||
|
TRUST BOUNDARY / INDEPENDENT PROVENANCE
|
||||||
|
The exact-commit archive URL binds source identity under the configured repository provider's authenticated mapping of commit ID to response bytes. The computed SHA-256 proves that the bytes executed are the bytes that exact URL served and that they did not change between fetch, installer consumption, and manifest verify-after. That is the measured control against stale-next substitution: SOURCE-CONTROL passed 5/5 exact-source runs and failed when R7 deleted the binding. The digest does NOT prove that those bytes are what the repository's authors published if the repository service or TLS trust root is compromised. Provider + TLS are the trust root by design. Independent signed provenance/authenticity remains an inherited deferral explicitly sourced to canonical greenfield-install PRD v2 §3, matching the already documented install.sh sidecar boundary. No stronger supply-chain claim is made. Codex security review's initial HIGH/CWE-494 finding is retained as the named trust-root deferral rather than bypassed or silently reclassified.
|
||||||
|
|
||||||
|
R7 DELETE-THE-SUBJECT MUTANT (EXECUTED)
|
||||||
|
Deleted the three MOSAIC_INSTALL_LOCAL_SOURCE_{ARCHIVE,COMMIT,SHA256} binding exports from tools/e2e-install-test.sh temporarily while retaining the realised source assertion.
|
||||||
|
- fixture rc: 1
|
||||||
|
- realised source commit reverted to 4df478cdd150fdf8d52ea109f02ade5d85017acd
|
||||||
|
- expected source commit remained ff3f0d29f1763bed44a60610d073036112e66b77
|
||||||
|
- realised source SHA-256: 3e6d831efe13c3b2c0501507099d4a566af5abf877dacf85e5d7e4284d35e5c7
|
||||||
|
- expected source SHA-256: 944c6db1b01b23c83169e6dc83e0d31262b1d24a2825270441745eb65c269c25
|
||||||
|
- SOURCE-CONTROL: FAIL
|
||||||
|
- comparator rc: 1; failures=8/checks=19
|
||||||
|
- elapsed: 171s; load before=4.13 4.71 8.05; load after=4.94 4.58 7.42
|
||||||
|
- subject file SHA-256 before mutant: a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98
|
||||||
|
- subject file SHA-256 after restoration: a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98
|
||||||
|
|
||||||
|
EVIDENCE LOGS (LOCAL, NOT COMMITTED RAW TRANSCRIPTS)
|
||||||
|
- /tmp/c1-round3-red.log and /tmp/c1-round3-red-summary.txt
|
||||||
|
- /tmp/c1-round3-green.log and /tmp/c1-round3-green-summary.txt
|
||||||
|
- /tmp/c1-round3-green-comparator.log
|
||||||
|
- /tmp/c1-round3-checkout-control.log and /tmp/c1-round3-checkout-summary.txt
|
||||||
|
- /tmp/c1-round3-repro-n5.tsv and /tmp/c1-round3-repro-{3,4,5}.log
|
||||||
|
- /tmp/c1-round3-final-r7.log and /tmp/c1-round3-final-r7-summary.txt
|
||||||
|
|
||||||
|
BASELINES / INDEPENDENT REVIEW
|
||||||
|
- bash -n tools/e2e-install-test.sh: PASS
|
||||||
|
- shellcheck tools/e2e-install-test.sh: PASS
|
||||||
|
- invalid remote source commit control: rc=2, named 40-hex requirement
|
||||||
|
- pnpm test:installer: PASS
|
||||||
|
- pnpm typecheck: PASS, 45/45 tasks
|
||||||
|
- pnpm lint: PASS, 25/25 tasks
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
- Codex code review: APPROVE, confidence 0.92, 6 files, zero findings
|
||||||
|
- Initial Codex security review: HIGH/CWE-494 on independent provider-compromise provenance; retained and bounded explicitly in PRD/report as the canonical v2 §3 deferral
|
||||||
|
- Codex security re-review after trust-boundary documentation: risk NONE, confidence 0.96, 6 files, zero findings
|
||||||
|
|
||||||
|
PUSH / CI
|
||||||
|
Not yet recorded in this artifact. No CI polling is authorised after the single push.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#1050 C1 ROUND 4 — PIPELINE CASE-COVERAGE DENOMINATOR
|
||||||
|
|
||||||
|
IDENTITY / SCOPE
|
||||||
|
- predecessor head: 0e2eef1c1261444b83c089df6047a2a06592d5c6
|
||||||
|
- branch: feat/1050-install-state-machine-red-fixture
|
||||||
|
- only open requirement: pipeline-level expected-case/executed-case set equality
|
||||||
|
- unchanged/fenced: tools/fixtures/greenfield-expected-red.tsv, tools/verify-greenfield-expected-red.sh, #869, P6 detector/producer, absolute-P3 CLI linking, and per-case fixture dispositions
|
||||||
|
- status language: believed-fixed, pending jarvis validation
|
||||||
|
|
||||||
|
STALE-RULING WITHDRAWAL HONOURED
|
||||||
|
Requirements 1-3 were withdrawn after pipeline 2242 at 0e2eef1c reached terminal green and showed P6 failure propagation, absolute-P3 CLI use, and an executed git-absent arm. A temporary local P6-consumer edit made while the stale ruling was in flight was restored before this change set; tools/e2e-install-test.sh has no final-tree delta. Requirement 5 alone remains in this round.
|
||||||
|
|
||||||
|
RED-FIRST
|
||||||
|
Before the change, .woodpecker/greenfield-install.yml had no pipeline-level initialized marker store, no per-case success marker, and no final exact-set gate. A static assertion for a final run-on-failure case-coverage step failed. Pipeline 2242's green result proved that all three cases ran once, but no gate required that population on a later run.
|
||||||
|
|
||||||
|
IMPLEMENTATION
|
||||||
|
- tools/verify-greenfield-case-coverage.sh is a new pipeline-level instrument; the existing per-case verifier is unchanged.
|
||||||
|
- Expected case names are derived from the first TSV field of tools/fixtures/greenfield-expected-red.tsv and sorted uniquely. There is no literal expected count.
|
||||||
|
- Marker storage is scoped by ${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}. The init action validates the manifest and empties only that run directory, so stale markers cannot satisfy a retry or later run.
|
||||||
|
- Every case/contract step explicitly depends on initialization. Each unconditional case step calls mark only after its exact tools/verify-greenfield-expected-red.sh invocation succeeds.
|
||||||
|
- The final greenfield-case-denominator step explicitly depends on all four case/contract steps, has status eligibility [success, failure], emits cases_defined=N cases_executed=M, and compares sorted expected and actual case-name sets. Missing or unexpected names fail even when the two counts are equal.
|
||||||
|
- A newly defined manifest case changes the expected set automatically and fails until a corresponding successful case step marks it.
|
||||||
|
|
||||||
|
MEASURED CONTROLS
|
||||||
|
1. Skipped expected arm:
|
||||||
|
rc=1
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=2
|
||||||
|
[fixture-suite] missing_case=next-git-absent
|
||||||
|
2. Count-inflation control (one expected missing, one unexpected added):
|
||||||
|
rc=1
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=3
|
||||||
|
[fixture-suite] missing_case=next-git-absent
|
||||||
|
[fixture-suite] unexpected_case=unexpected-case
|
||||||
|
3. Stale-marker initialization control (same run re-initialized after markers existed):
|
||||||
|
rc=1
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=0
|
||||||
|
missing_case rows emitted for all three expected names
|
||||||
|
4. Exact-set positive control:
|
||||||
|
rc=0
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=3
|
||||||
|
5. Future manifest case control:
|
||||||
|
rc=1
|
||||||
|
[fixture-suite] cases_defined=4 cases_executed=3
|
||||||
|
[fixture-suite] missing_case=future-case
|
||||||
|
|
||||||
|
WORKFLOW / FOCUSED VALIDATION
|
||||||
|
- bash -n new subject and test: PASS
|
||||||
|
- shellcheck new subject and test: PASS
|
||||||
|
- bash tools/verify-greenfield-case-coverage.test.sh: PASS
|
||||||
|
- test statically proves each mark follows its exact per-case verifier, every producer depends on initialization, the final gate depends on the complete matrix, and final status eligibility includes success+failure
|
||||||
|
- woodpecker-cli lint --strict .woodpecker/greenfield-install.yml: PASS
|
||||||
|
- Prettier check for workflow/package/PRD: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
|
||||||
|
CURRENT-HEAD FIXTURE MEASUREMENTS TAKEN BEFORE THE STALE RULING WAS WITHDRAWN
|
||||||
|
These measurements were run from 0e2eef1c before requirement 5 changed only pipeline instrumentation. They are retained as measured outcomes, not as justification for modifying requirements 1-3.
|
||||||
|
- next checkout/git-present: fixture rc=1; resolved @next=0.0.50-next.2207; P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8/P9 FAIL; installer_exit=1; done_claims=0; P6 reason was dead enforcement hooks active count=2.
|
||||||
|
- next checkout/git-absent: fixture rc=1; P0/P6/P7 PASS; P1/P2/P3/P4/P5/P8/P9 FAIL; installer_exit=1; done_claims=0.
|
||||||
|
- /home free before the first run: 8.5G; after concurrent authorized fleet reclaim completed: 11G; no be-coder-05 generated payload was deleted because active fixture work made that churn.
|
||||||
|
|
||||||
|
INDEPENDENT REVIEW CYCLE
|
||||||
|
- Initial Codex code review: REQUEST_CHANGES, confidence 0.96; one blocker found that the first draft omitted depends_on ordering and could race initialization/final checking.
|
||||||
|
- Disposition: accepted. Added explicit init -> all case/contract steps -> final dependency graph and a regression assertion for every dependency edge.
|
||||||
|
- Initial Codex security review: risk NONE, confidence 0.94, zero findings. The sandbox could not create its own temp directory; local focused/full tests provide the dynamic evidence.
|
||||||
|
- First post-dependency code re-review: REQUEST_CHANGES, confidence 0.99; the case extractor treated the production manifest's comment lines as case names, while the synthetic test omitted comments.
|
||||||
|
- Disposition: accepted. Case derivation now excludes blank/comment lines; the focused test includes representative comments and executes init/mark/check against the repository's real manifest.
|
||||||
|
- Post-dependency security re-review: risk NONE, confidence 0.96, zero findings.
|
||||||
|
- Final Codex code re-review after the comment fix: APPROVE, confidence 0.94, 8 files, zero findings. It confirmed ordering, run scope, fail-closed set equality, PRD alignment, and negative controls.
|
||||||
|
- Final Codex security re-review: risk NONE, confidence 0.96, 8 files, zero findings.
|
||||||
|
|
||||||
|
FINAL LOCAL BASELINES
|
||||||
|
- pnpm test:installer: PASS, including the new coverage suite
|
||||||
|
- pnpm typecheck: PASS, 45/45
|
||||||
|
- pnpm lint: PASS, 25/25
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- bash -n + ShellCheck for new shell surfaces: PASS
|
||||||
|
- Woodpecker strict lint: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
- /home free before/after local baselines: 11G
|
||||||
|
|
||||||
|
PENDING BEFORE PUSH
|
||||||
|
- commit, queue guard, one push, provider attribution read-back
|
||||||
|
- announce the new full SHA to rev-security-02; do not merge or close
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#1050 C1 ROUND 5 — ARM COVERAGE + CHECKOUT PURITY
|
||||||
|
|
||||||
|
IDENTITY / SCOPE
|
||||||
|
- predecessor head: f33bd0da96bee3ce518744d6888d018bebb1c841
|
||||||
|
- branch: feat/1050-install-state-machine-red-fixture
|
||||||
|
- review inputs: be-coder-06 review 110; rev-security-02 review 111
|
||||||
|
- in scope: exact required pipeline-arm set including greenfield-remote-installer-contract; success-only arm marks; skipped-remote RED control; coverage-state exclusion from checkout source archives
|
||||||
|
- unchanged/fenced: tools/fixtures/greenfield-expected-red.tsv, tools/verify-greenfield-expected-red.sh, tools/e2e-install-test.sh, #869, and all per-case dispositions
|
||||||
|
- status: believed-fixed, pending jarvis validation
|
||||||
|
|
||||||
|
RED-FIRST — FOUR ARMS WERE REPRESENTED BY THREE CASES
|
||||||
|
The f33bd0d gate represented only the three expected-RED case identities. With all three case names marked and no remote-arm identity available, the production checker returned:
|
||||||
|
SKIPPED_REMOTE_ARM_CURRENT_GATE_RC=0
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=3
|
||||||
|
The fourth required arm, greenfield-remote-installer-contract, reused next-git-present and therefore could skip without changing case-set equality.
|
||||||
|
|
||||||
|
RED-FIRST — COVERAGE STATE CONTAMINATED CHECKOUT ARCHIVES
|
||||||
|
The f33bd0d workflow created .greenfield-case-state in the shared repository before the checkout-mode fixture arms. An independent reproduction using the exact e2e-install-test.sh tar exclusions created .greenfield-case-state/remote-arm.ran and measured:
|
||||||
|
OLD_STATE_ARCHIVE_MATCHES=1
|
||||||
|
The state instrument was therefore part of the checkout source payload it was intended only to observe.
|
||||||
|
|
||||||
|
IMPLEMENTATION
|
||||||
|
- Renamed the checker to tools/verify-greenfield-execution-coverage.sh and generalized it over two validated dimensions: cases and arms.
|
||||||
|
- Added tools/fixtures/greenfield-expected-arms.txt as the explicit required arm set:
|
||||||
|
greenfield-git-present
|
||||||
|
greenfield-main-git-present
|
||||||
|
greenfield-remote-installer-contract
|
||||||
|
greenfield-git-absent
|
||||||
|
- Expected cases remain derived from the unchanged expected-RED TSV. Expected arms are derived from the new one-name-per-line arm declaration. Both inputs allow comments/blanks and validate every resulting name.
|
||||||
|
- Each of the four fixture arms writes its unique arm marker only after its exact per-case verifier succeeds. The three distinct expected-RED cases continue to write case markers after successful verification.
|
||||||
|
- The final step checks both dimensions even when the first check fails, emits both denominators, and fails unless both exact name sets match.
|
||||||
|
- Coverage state moved to .mosaic-test-work/greenfield-execution-coverage/${CI_PIPELINE_NUMBER}-${CI_WORKFLOW_NUMBER}/{cases,arms}. The checkout archive already excludes every */.mosaic-test-work subtree.
|
||||||
|
- The expected-RED manifest, per-case verifier, and detector are untouched.
|
||||||
|
|
||||||
|
MEASURED SKIPPED-REMOTE CONTROL
|
||||||
|
With all three cases marked and only the three non-remote arms marked:
|
||||||
|
case gate rc=0
|
||||||
|
[fixture-suite] cases_defined=3 cases_executed=3
|
||||||
|
arm gate rc=1
|
||||||
|
[fixture-suite] arms_defined=4 arms_executed=3
|
||||||
|
[fixture-suite] missing_arm=greenfield-remote-installer-contract
|
||||||
|
This is the discriminating control: the case denominator remains green while the arm denominator catches the exact previously invisible skip.
|
||||||
|
|
||||||
|
OTHER DYNAMIC CONTROLS
|
||||||
|
- missing case: cases 3/2 => rc1
|
||||||
|
- case count inflation (one missing + one unexpected): cases 3/3 => rc1
|
||||||
|
- stale case markers after re-init: cases 3/0 => rc1
|
||||||
|
- exact case set: cases 3/3 => rc0
|
||||||
|
- future manifest case: cases 4/3 => rc1
|
||||||
|
- arm count inflation (remote missing + unexpected): arms 4/4 => rc1
|
||||||
|
- exact arm set: arms 4/4 => rc0
|
||||||
|
- production expected-RED manifest comments/blanks: accepted; exact set 3/3 => rc0
|
||||||
|
- archive selector firing control: a non-excluded root marker is present in the archive
|
||||||
|
- archive purity control: no .mosaic-test-work path is present in the archive
|
||||||
|
|
||||||
|
STATIC / STRUCTURAL CONTROLS
|
||||||
|
- every case mark follows that arm's successful per-case verifier
|
||||||
|
- every arm mark, including the remote contract, follows that arm's successful per-case verifier
|
||||||
|
- all four arms depend on coverage initialization
|
||||||
|
- final denominator depends on all four arms and is eligible after success or failure
|
||||||
|
- final denominator runs both checks and aggregates their statuses
|
||||||
|
- workflow contains the already-excluded .mosaic-test-work state root and no .greenfield-case-state reference
|
||||||
|
|
||||||
|
LOCAL BASELINES
|
||||||
|
- bash -n + ShellCheck on generalized checker/test: PASS
|
||||||
|
- pnpm test:installer: PASS, including execution-coverage controls
|
||||||
|
- pnpm typecheck: PASS, 45/45
|
||||||
|
- pnpm lint: PASS, 25/25
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- woodpecker-cli lint --strict: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
- /home free before/after: 11G
|
||||||
|
|
||||||
|
INDEPENDENT REVIEW
|
||||||
|
- Codex code review: APPROVE, confidence 0.93, zero findings. It confirmed exact arm coverage, success-only marking, dual-status aggregation, excluded state, checkout-purity controls, and PRD alignment.
|
||||||
|
- Codex security review: risk NONE, confidence 0.96, zero findings. CI/path identifiers are constrained, expansions quoted, expected sets treated as data, and checks fail closed.
|
||||||
|
- Review sandboxes could not execute their own dynamic suite/ShellCheck because their filesystem is read-only; the local measured baselines above provide that evidence.
|
||||||
|
|
||||||
|
PENDING
|
||||||
|
- commit, queue guard, one push, attribution read-back
|
||||||
|
- announce the new full SHA; no merge or closure
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
# #1050 C1 fix round — Round 6 caller coupling and production archive binding
|
||||||
|
|
||||||
|
BASE HEAD
|
||||||
|
- df705828a439c6795cd47c938ed7f838b956ebf3
|
||||||
|
- Reviews 114 (rev-security-02) and 115 (be-coder-06) independently reproduced the same caller-level mutant: ignoring arms_status in the workflow left the focused helper suite green.
|
||||||
|
- Both reviewers confirmed the production aggregation was correct and requested test reachability, not a logic rewrite.
|
||||||
|
- rev-974 separately deleted only the production e2e-install-test.sh .mosaic-test-work tar exclusion; the copied-selector suite remained green.
|
||||||
|
|
||||||
|
RED
|
||||||
|
- After adding the four-cell caller truth-table expectations but before creating the workflow helper:
|
||||||
|
bash tools/verify-greenfield-execution-coverage.test.sh => rc=1
|
||||||
|
The expected helper did not exist, so the test could not reach a passing aggregation implementation.
|
||||||
|
- Prior independent controls:
|
||||||
|
ignore-arms workflow mutant => rc=0 before this round (reviews 114/115)
|
||||||
|
delete-production-archive-exclusion mutant => rc=0 before this round (rev-974 exact-head sandbox)
|
||||||
|
|
||||||
|
IMPLEMENTATION
|
||||||
|
- Extracted the existing set +e / two checks / status capture / final OR into tools/verify-greenfield-execution-coverage-gate.sh without changing its semantics.
|
||||||
|
- .woodpecker/greenfield-install.yml now invokes that helper directly; its inline duplicate was removed.
|
||||||
|
- The focused suite invokes the same helper used by the workflow across all four cells:
|
||||||
|
cases PASS / arms FAIL => RED
|
||||||
|
cases FAIL / arms PASS => RED
|
||||||
|
both FAIL => both diagnostic outputs and RED
|
||||||
|
both PASS => GREEN
|
||||||
|
- The archive-purity test now first locates the production checkout-archive command in tools/e2e-install-test.sh and requires its .mosaic-test-work exclusion, then proves the exclusion's archive effect.
|
||||||
|
- GREENFIELD_COVERAGE_GATE and GREENFIELD_E2E_INSTALLER are test-only subject injection seams used to prove the requested mutants; production paths remain the defaults.
|
||||||
|
|
||||||
|
MUTATION CONTROLS
|
||||||
|
- ignore-arms aggregation helper => focused suite rc=1
|
||||||
|
- ignore-cases aggregation helper => focused suite rc=1
|
||||||
|
- always-RED aggregation helper => focused suite rc=1
|
||||||
|
- delete only production .mosaic-test-work archive exclusion => focused suite rc=1
|
||||||
|
- restored production subjects => greenfield execution coverage tests passed
|
||||||
|
- final mutant workspace: /tmp/c1-round6-final-mutants.u0ZaL6
|
||||||
|
- logs: /tmp/c1-round6-final-gate-ignore-arms.log, /tmp/c1-round6-final-gate-ignore-cases.log, /tmp/c1-round6-final-gate-always-red.log, /tmp/c1-round6-final-archive-mutant.log
|
||||||
|
|
||||||
|
FENCED FILES
|
||||||
|
- tools/e2e-install-test.sh HEAD/worktree SHA-256: a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98 (byte-identical)
|
||||||
|
- tools/fixtures/greenfield-expected-red.tsv HEAD/worktree SHA-256: baae06c664d0ab7ffe4c32ec106bfcb6f9dd74b46461d3c4440b73dc582af1a2 (byte-identical)
|
||||||
|
- No per-case disposition or #869 wiring changed.
|
||||||
|
|
||||||
|
LOCAL BASELINES
|
||||||
|
- /home free before build: 11G
|
||||||
|
- bash -n focused scripts: PASS
|
||||||
|
- ShellCheck focused scripts: PASS
|
||||||
|
- bash tools/verify-greenfield-execution-coverage.test.sh: PASS
|
||||||
|
- pnpm test:installer: PASS
|
||||||
|
- pnpm typecheck: PASS (45/45)
|
||||||
|
- pnpm lint: PASS (25/25)
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- woodpecker-cli lint --strict .woodpecker/greenfield-install.yml: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
|
||||||
|
INDEPENDENT REVIEW
|
||||||
|
- Codex code review: APPROVE, confidence 0.94, seven files reviewed, zero findings. It confirmed the workflow invokes the exact four-cell-tested helper, both diagnostics remain observable, and archive purity binds to the production selector.
|
||||||
|
- Codex security review: risk NONE, confidence 0.97, seven files reviewed, zero findings. It confirmed fail-closed behavior, quoted inputs, repository-controlled production arguments, and no new secret/injection/access-control exposure.
|
||||||
|
- The code-review sandbox could not execute the dynamic suite because its filesystem is read-only; the local measured baselines and mutation controls above provide dynamic evidence.
|
||||||
|
|
||||||
|
PENDING
|
||||||
|
- commit, queue guard, one lease-pinned push, provider attribution read-back
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# #1050 C1 fix round — Round 7 canonical Alpine/BusyBox portability
|
||||||
|
|
||||||
|
BASE HEAD
|
||||||
|
- d66e91b1f22489b911eef982aec3faa62c5edeb3
|
||||||
|
- Review 119 (rev-974) found tools/verify-greenfield-execution-coverage.sh used GNU-only find -printf.
|
||||||
|
- Pipeline 2246's canonical Alpine ci-base test failed while the Debian greenfield workflow passed; the stale pipeline is diagnostic evidence only and is not inherited as a ruling for the replacement head.
|
||||||
|
|
||||||
|
RED
|
||||||
|
- Before remediation:
|
||||||
|
docker run --rm -v "$PWD:/workspace" -w /workspace git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash tools/verify-greenfield-execution-coverage.test.sh
|
||||||
|
=> canonical_alpine_red_rc=1
|
||||||
|
- Independent review 119 captured BusyBox 1.37 rejecting `find: unrecognized: -printf`.
|
||||||
|
- Debian/GNU execution remained green, proving that one runtime's success did not establish portability.
|
||||||
|
|
||||||
|
IMPLEMENTATION
|
||||||
|
- Removed the GNU find -printf inventory.
|
||||||
|
- Marker inventory now uses Bash nullglob/dotglob plus parameter expansion, all available in the script's declared Bash runtime.
|
||||||
|
- The checker verifies the run directory is readable/searchable before inventory and fails closed otherwise.
|
||||||
|
- Only direct regular, non-symlink `*.ran` files are included, preserving the prior find boundary; identities remain sorted and compared as exact sets.
|
||||||
|
- No production fixture disposition, expected-RED manifest, e2e installer, #869 wiring, or acquisition behavior changed.
|
||||||
|
|
||||||
|
GREEN
|
||||||
|
- Local focused suite: greenfield execution coverage tests passed.
|
||||||
|
- Canonical Alpine/BusyBox focused suite:
|
||||||
|
docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/workspace" -w /workspace git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash tools/verify-greenfield-execution-coverage.test.sh
|
||||||
|
=> greenfield execution coverage tests passed.
|
||||||
|
- Existing Round-6 controls retained:
|
||||||
|
ignore-arms aggregation helper => rc=1
|
||||||
|
ignore-cases aggregation helper => rc=1
|
||||||
|
always-RED aggregation helper => rc=1
|
||||||
|
delete production .mosaic-test-work archive exclusion => rc=1
|
||||||
|
- Mutant workspace: /tmp/c1-round7-mutants.LocpBH
|
||||||
|
|
||||||
|
FENCED FILES
|
||||||
|
- tools/e2e-install-test.sh HEAD/worktree SHA-256: a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98 (byte-identical)
|
||||||
|
- tools/fixtures/greenfield-expected-red.tsv HEAD/worktree SHA-256: baae06c664d0ab7ffe4c32ec106bfcb6f9dd74b46461d3c4440b73dc582af1a2 (byte-identical)
|
||||||
|
|
||||||
|
LOCAL BASELINES
|
||||||
|
- /home free before and after validation: 11G
|
||||||
|
- Bash syntax focused scripts: PASS
|
||||||
|
- ShellCheck focused scripts: PASS
|
||||||
|
- pnpm test:installer: PASS
|
||||||
|
- pnpm typecheck: PASS (45/45)
|
||||||
|
- pnpm lint: PASS (25/25)
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- woodpecker-cli lint --strict .woodpecker/greenfield-install.yml: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
|
||||||
|
INDEPENDENT REVIEW
|
||||||
|
- Codex code review: APPROVE, confidence 0.96, zero findings. It confirmed hidden/empty/direct-regular/non-symlink semantics, exact sorting/comparison, dual-runtime execution, and PRD alignment.
|
||||||
|
- Codex security review: risk NONE, confidence 0.97, zero findings. It confirmed quoted paths, fail-closed exact-set behavior, and no new injection, traversal, authorization, secret, cryptographic, dependency, or data-integrity risk.
|
||||||
|
- The security-review sandbox could not execute the focused suite because its filesystem is read-only; the local and canonical-container measured runs above provide dynamic evidence.
|
||||||
|
|
||||||
|
PENDING
|
||||||
|
- commit, queue guard, one lease-pinned push, provider attribution read-back
|
||||||
|
- replacement-head canonical CI; no manual pipeline trigger or polling
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# #1050 C1 fix round — Round 8 pipefail-safe expected-set membership
|
||||||
|
|
||||||
|
BASE HEAD
|
||||||
|
- 58ada98d2b430da28c46cf92eebdb6d45b8b1846
|
||||||
|
- Review 122 (rev-security-02) found expected_names | grep -Fxq could falsely reject valid markers under pipefail when grep closed the pipe after an early match and the producer exited 141.
|
||||||
|
- Independent canonical-Alpine live-manifest stress reproduced intermittent false failures at the current four-arm set size. No small-manifest safe threshold is claimed.
|
||||||
|
|
||||||
|
DETERMINISTIC RED
|
||||||
|
- The focused suite now generates a 20,001-name manifest with a valid target sorted first and invokes the real mark path.
|
||||||
|
- Before remediation, in canonical Alpine ci-base:
|
||||||
|
docker run --rm -u "$(id -u):$(id -g)" -v "$PWD:/workspace" -w /workspace git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash tools/verify-greenfield-execution-coverage.test.sh
|
||||||
|
=> canonical_sigpipe_tdd_red_rc=1
|
||||||
|
=> [fixture-suite] case is not in the expected set: a-target
|
||||||
|
- This control exercises the production checker invocation rather than an approximated producer and makes the early-close race deterministic by exceeding pipe capacity.
|
||||||
|
|
||||||
|
IMPLEMENTATION
|
||||||
|
- The mark path now fully materializes expected_names into expected_snapshot and fails closed if production fails.
|
||||||
|
- grep -Fxq reads the completed snapshot through a here-string; there is no producer/consumer pipeline to close early.
|
||||||
|
- Missing names still fail with the same attributable message. No `|| true` or other failure suppression was introduced.
|
||||||
|
- Exact-set checks, marker inventory, aggregation, archive binding, fixture dispositions, expected-RED manifest, e2e installer, #869 wiring, and acquisition behavior are unchanged.
|
||||||
|
|
||||||
|
GREEN
|
||||||
|
- Local focused suite: greenfield execution coverage tests passed.
|
||||||
|
- Canonical Alpine focused suite: greenfield execution coverage tests passed.
|
||||||
|
- Canonical Alpine live production-manifest stress: 1,000 valid greenfield-git-absent arm marks, failures=0.
|
||||||
|
- Canonical grep-q pipeline mutant through the real checker path => rc=1 with the attributable false rejection; restored subject => GREEN.
|
||||||
|
- Existing Round-6 controls retained:
|
||||||
|
ignore-arms aggregation helper => rc=1
|
||||||
|
ignore-cases aggregation helper => rc=1
|
||||||
|
always-RED aggregation helper => rc=1
|
||||||
|
delete production .mosaic-test-work archive exclusion => rc=1
|
||||||
|
- Round-8 mutant workspace: /tmp/c1-round8-mutants.aS1xTI
|
||||||
|
|
||||||
|
FENCED FILES
|
||||||
|
- tools/e2e-install-test.sh HEAD/worktree SHA-256: a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98 (byte-identical)
|
||||||
|
- tools/fixtures/greenfield-expected-red.tsv HEAD/worktree SHA-256: baae06c664d0ab7ffe4c32ec106bfcb6f9dd74b46461d3c4440b73dc582af1a2 (byte-identical)
|
||||||
|
|
||||||
|
LOCAL BASELINES
|
||||||
|
- /home free before and after validation: 11G
|
||||||
|
- Bash syntax focused scripts: PASS
|
||||||
|
- ShellCheck focused scripts: PASS
|
||||||
|
- pnpm test:installer: PASS
|
||||||
|
- pnpm typecheck: PASS (45/45)
|
||||||
|
- pnpm lint: PASS (25/25)
|
||||||
|
- pnpm format:check: PASS
|
||||||
|
- woodpecker-cli lint --strict .woodpecker/greenfield-install.yml: PASS
|
||||||
|
- git diff --check: PASS
|
||||||
|
|
||||||
|
INDEPENDENT REVIEW
|
||||||
|
- Codex code review: APPROVE, confidence 0.96, six files reviewed, zero findings. It confirmed full materialization removes the SIGPIPE race and the deterministic regression control exercises the real early-match mark path.
|
||||||
|
- Codex security review: risk NONE, confidence 0.98, six files reviewed, zero findings. It confirmed fail-closed producer handling, constrained/quoted names, and no new injection, traversal, secret, access-control, cryptographic, dependency, or logging risk.
|
||||||
|
- The code-review sandbox could not execute the suite or install ShellCheck because its filesystem is read-only; local and canonical-container dynamic evidence above supplies those gates.
|
||||||
|
|
||||||
|
PENDING
|
||||||
|
- commit, queue guard, one lease-pinned push, provider attribution read-back
|
||||||
|
- replacement-head canonical CI; no manual trigger or polling
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# #1050 C1 fix-round verification
|
||||||
|
|
||||||
|
Frozen reviewed head before remediation: `378bc1afe3bc485adb8614897d66c5edccd4a527`.
|
||||||
|
|
||||||
|
Status: **believed-fixed, pending jarvis validation**. PR #1054 is not self-merged and issue #1050 remains open.
|
||||||
|
|
||||||
|
## Blocker B — fail-closed test enumeration
|
||||||
|
|
||||||
|
The RED-first control used a real filesystem permission failure, not binary shadowing or PATH interception.
|
||||||
|
|
||||||
|
1. A planted `{"status":"in-progress"}` file in a readable P2 fault tree made the complete real walk fail the frozen suite at `P2 left an in-progress transaction` (`01-pre-fix-positive-control.log`, exit 1).
|
||||||
|
2. The same planted defect beneath a target-owned mode-0100 directory made real `find` report a permission failure. The frozen suite erased the producer failure and exited 0 with `installer next lane tests passed` (`02-pre-fix-permission-failure-attack.log`).
|
||||||
|
3. The committed regression control initially failed because the child full-suite attack still exited 0 (`03-regression-test-red.log`).
|
||||||
|
4. After remediation, the same child full-suite input exits 1 and names `[test] ERROR: P2 fault-state enumeration failed` (`04-post-fix-permission-failure-attack.log`). The ordinary full suite remains green.
|
||||||
|
|
||||||
|
`tools/test-enumeration-assertions.sh` now captures each complete NUL-delimited population and checks the producer status before asserting absence. Content checks inspect the captured population and distinguish “no match” from a read error. The shared fail-closed implementation covers:
|
||||||
|
|
||||||
|
- `tools/install-next-lane.test.sh`: redacted staging-file cleanup and fault-state transaction scan;
|
||||||
|
- `tools/verified-installer-fetch.test.sh`: temporary-download cleanup;
|
||||||
|
- `tools/install-state-machine.test.sh`: symlink-target non-mutation;
|
||||||
|
- `docs/reports/verification/1050-b8-redaction-control/positive-control.test.sh`: both copied counterparts.
|
||||||
|
|
||||||
|
No assertion was loosened. A1, A2, upgrade-guard, source-root, the species-2 sweep, #869, and expected-RED verdict rows remain outside this remediation.
|
||||||
|
|
||||||
|
## Blocker A — installer digest
|
||||||
|
|
||||||
|
The stale sidecar value was replaced with the exact `sha256sum` record for `tools/install.sh`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
e59cb441a2f37ae9150f8eae470238e9d858a1816df93343d9784a6796676096 install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
RED-first documentation control from the repository root: `sha256sum -c tools/install.sh.sha256` exits 1 with `install.sh: FAILED open or read` because the sidecar records a path relative to `tools/`. The command actually executed, `(cd tools && sha256sum -c install.sh.sha256)`, exits 0 with `install.sh: OK`; the workflow's separate exact expected/actual equality also passes. The immutable provider-fetch arm at the new `${CI_COMMIT_SHA}` is recorded in the freeze artifact after push; local equality alone is not treated as sufficient evidence.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Documentation completion checklist — #1050 C1 fix round
|
||||||
|
|
||||||
|
## Required artifacts
|
||||||
|
|
||||||
|
- [x] `docs/PRD.md` exists; #1050 C1 requirement 13 binds the complete in-scope remote stack source chain, and requirement 15 binds exact case+arm execution coverage, executable aggregation, production-bound archive purity, dual-runtime portability, and pipefail-safe materialized membership.
|
||||||
|
- [x] User guide: not applicable; no user-facing production installer behavior changed.
|
||||||
|
- [x] Admin guide: not applicable; no operator procedure or deployment behavior changed.
|
||||||
|
- [x] Developer guide: existing `docs/guides/installer-state-machine.md` defines the fail-closed installer model; the C1-specific remote-arm contract and evidence live in the PRD, scratchpad, and scoped verification report.
|
||||||
|
- [x] OpenAPI and endpoint index: not applicable; no API changed.
|
||||||
|
- [x] Sitemap: not applicable; no navigation changed.
|
||||||
|
|
||||||
|
## API and structural coverage
|
||||||
|
|
||||||
|
- [x] API schema/auth/error coverage: not applicable; no endpoint changed.
|
||||||
|
- [x] Guide book indexes: not applicable; no guide page was added or moved.
|
||||||
|
- [x] Root hygiene preserved; all new artifacts are under `docs/reports/verification/1050-c1-fix-round/` and the active scratchpad remains under `docs/scratchpads/`.
|
||||||
|
|
||||||
|
## Review and publishing
|
||||||
|
|
||||||
|
- [x] Verification documentation is in the same logical change set as the shell-test remediation.
|
||||||
|
- [x] Trust boundary states both halves: the digest proves fetched bytes remain identical through execution/verify-after; it does not authenticate authorship against provider/TLS compromise. Independent provenance is the inherited PRD v2 §3 deferral.
|
||||||
|
- [x] Round-3 Codex code review APPROVE (confidence 0.92, zero findings) and security re-review risk NONE (confidence 0.96, zero findings). The initial HIGH trust-root finding remains documented as the canonical signed-provenance deferral, not hidden.
|
||||||
|
- [x] Round-4 review cycle closed: two blocking Codex findings (missing dependency ordering; production manifest comments misparsed) were accepted and fixed. Final code review APPROVE (confidence 0.94, zero findings) and security risk NONE (confidence 0.96, zero findings).
|
||||||
|
- [x] Round-5 exact arm coverage and archive-purity controls reviewed: Codex code APPROVE (confidence 0.93, zero findings) and security risk NONE (confidence 0.96, zero findings).
|
||||||
|
- [x] Round-6 executable aggregation and production archive-selector binding reviewed: Codex code APPROVE (confidence 0.94, zero findings) and security risk NONE (confidence 0.97, zero findings).
|
||||||
|
- [x] Round-7 BusyBox-safe marker inventory and dual-runtime evidence reviewed: Codex code APPROVE (confidence 0.96, zero findings) and security risk NONE (confidence 0.97, zero findings).
|
||||||
|
- [x] Round-8 materialized membership and deterministic canonical-Alpine control reviewed: Codex code APPROVE (confidence 0.96, zero findings) and security risk NONE (confidence 0.98, zero findings).
|
||||||
|
- [x] Canonical evidence remains in-repo. No external publishing action was requested or performed.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# #1050 successor remediation verification
|
||||||
|
|
||||||
|
Head under test before remediation: `e5d5c8495a070af2dcd393cace287fe74a8a819e`.
|
||||||
|
|
||||||
|
This change strengthens the expected-RED detector; it does not repair the intentionally failing greenfield rows. The #869 hooks remain unwired.
|
||||||
|
|
||||||
|
## A1 — P0 reason binding
|
||||||
|
|
||||||
|
RED first:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ bash tools/verify-greenfield-expected-red.test.sh
|
||||||
|
[test] FAIL: vacuous P0 PASS satisfied the expected-RED contract without identity/context evidence
|
||||||
|
exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
The pinned manifest now has an explicit `phase-reason` binding against the final P0 row: target `mosaic`, uid `1001`, equal `HOME` and passwd HOME, `/bin/bash`, `privilege=user`, `x86_64`, glibc, and version-shaped Node/npm evidence. All three cases structurally require exactly one P0 reason binding. The greenfield fixture's final P0 row now emits and validates the same complete identity/context evidence, so an unrelated earlier P0 line cannot satisfy the binding for a vacuous final row.
|
||||||
|
|
||||||
|
Pipeline 2224 and the successor's pre-change fixture run also exposed a stale next-lane P6 reason left behind by the already-closed B6 remediation: actual behavior is a fail-closed runtime-link action refusal with `#869` hooks left inactive and a persisted required P6 failure, while the manifest still expected dead hooks to be active. The pinned reason now matches the stronger measured refusal (`runtime linking/activation action reported a required failure`); no verdict changed and #869 remains unwired.
|
||||||
|
|
||||||
|
GREEN:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[test] PASS: P0 PASS must bind target identity, HOME, shell, privilege, architecture, and runtime reason
|
||||||
|
```
|
||||||
|
|
||||||
|
## A2 — fail-closed P4 enumeration
|
||||||
|
|
||||||
|
RED first: a `find` control emitted only the safe root, omitted an unsafe mode-`0666` child, and exited `73`. The process-substitution consumer discarded that status:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[test] FAIL: P4 accepted a partial created-path inventory after find failed
|
||||||
|
[test] FAIL: P4 did not report failed created-path enumeration
|
||||||
|
exit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
P4 now captures the NUL-delimited walk into a temporary file, checks `find` to completion, and only then evaluates the complete inventory. A failed walk reports that enumeration failed and returns a P4 finding.
|
||||||
|
|
||||||
|
GREEN:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[test] PASS: P4 rejects an incomplete created-path inventory
|
||||||
|
[test] PASS: P4 attributes the failed created-path enumeration
|
||||||
|
```
|
||||||
|
|
||||||
|
## B — deterministic TERM no-exit control
|
||||||
|
|
||||||
|
Woodpecker pipeline 2224 at the original head reported `32 passed, 2 failed`: the no-exit fixture did not exit zero or report sync success. The premise was not stale: the same fixture passed `34/34` on another filesystem.
|
||||||
|
|
||||||
|
A controlled reverse-sorted `find -print0` walk reproduced the pipeline result exactly (`32 passed, 2 failed`). Root cause: signal injection was tied to `guides/E2E-DELIVERY.md`; whether required `tools/` content remained after restore depended on filesystem enumeration order. The test was measuring path order as well as trap semantics.
|
||||||
|
|
||||||
|
The generated fixtures now damage a real target path after the snapshot is armed, self-signal immediately before the complete normal sync, and differ only in the explicit handler exit. Therefore a no-exit handler always restores, returns, runs the full sync, mutates the restored target again, and reports completion independent of walk order.
|
||||||
|
|
||||||
|
GREEN on both native and reverse-sorted enumeration:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RESULT: 36 passed, 0 failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Mutation sensitivity: restoring `exit 1` to the nominal no-exit fixture makes the control RED (`32 passed, 4 failed`), including failures of the zero-exit and resumed-success assertions. The control can still fail for its stated reason.
|
||||||
|
|
||||||
|
## Enumeration-class sweep
|
||||||
|
|
||||||
|
The sweep covered production enumeration in `tools/install.sh` and `packages/mosaic/framework/install.sh`, plus process-substitution consumers in the C1 shell-test surfaces. Framework installer file, operator, durable-snapshot, and pruning walks already capture and check their producer status. P4's created-path walk was the reviewed unchecked instance.
|
||||||
|
|
||||||
|
One additional order/completeness dependency was found in source acquisition: `find "$WORK_DIR" ... | head -1` hid `find` failure and selected arbitrarily when an archive produced multiple top-level directories. RED first, the extraction fake produced two roots and the lane test stopped at that new assertion with exit 1 because today's code selected one. Source acquisition now captures and checks the complete NUL-delimited walk and requires exactly one extracted root. The lane suite is green with the multiple-root rejection. No remaining production installer enumeration uses unchecked process substitution or first-row order as authority.
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# MC-CRED-01 / stack #1045 scratchpad
|
|
||||||
|
|
||||||
Last updated: 2026-08-05
|
|
||||||
|
|
||||||
## Successor remediation (`be-coder-07`, PR #1059 review id 89)
|
|
||||||
|
|
||||||
Objective: close the four exact-head `rev-974` blockers without weakening any assertion, repair canonical-image test portability, and refresh the narrowed cooperative-concurrency claims and PR metadata. The provider-fetched starting head is `12958610cbafaa54a3db95327a7c3453d9111669` at merge-base `85d2108e4ed15c744ad3b87a5b629e7b2d39405a`.
|
|
||||||
|
|
||||||
Plan:
|
|
||||||
|
|
||||||
1. Preserve the journal-lock release assertion while replacing Alpine-invalid `/usr/bin/true` with canonical `/bin/true`.
|
|
||||||
2. Replace cross-system atomicity claims with exact all-or-verified-compensation semantics; retain atomic wording only for explicitly scoped single-file rename/replacement primitives and qualify team locking as cooperative advisory serialization.
|
|
||||||
3. Add red-first controls for complete journal writes under one-byte progress, rejection of zero/invalid progress, Tea generation equivalence under recursive key reorder, and durable indeterminate classification when team-lock release cannot be verified.
|
|
||||||
4. Implement bounded journal write-all, recursive canonical generation serialization, and release-before-seal team-lock cleanup classification; then run focused and baseline gates plus code/security advisories.
|
|
||||||
5. Commit with explicit `be-coder-07` identity, rebase rather than merge onto current `origin/main`, prove stable patch identity, run the required queue/direct-CI guards, and make one force-with-lease push pinned to the measured starting head.
|
|
||||||
|
|
||||||
Budget: hard context ceiling is 60%. Reuse predecessor evidence and avoid re-deriving unrelated 41-file history. Non-blocking secret zeroization is included only after all blockers are green. Stop and write a seam/report before the ceiling.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Deliver the governed `mosaic cred` identity boundary for issue, scope, validation, rotation, and revocation across explicitly declared estates. The trunk-only ruling superseded the original `next` checkpoint: the branch is rebased onto `origin/main` and its PR target is `main`. Linked issues remain **believed-fixed, pending jarvis validation** after merge.
|
|
||||||
|
|
||||||
## Requirements sources
|
|
||||||
|
|
||||||
- Charter: `/home/hermes/agent-work/tl-mosaic/CHARTER-MC-CRED-01-be-coder-06.md`
|
|
||||||
- Stack issues: #1045, #1043, #1044, #1047, #1049, #1013, #1007; promotion #1037; consumer #1051
|
|
||||||
- Remote spec: `jason.woltje/jarvis-brain` origin/main `b7687d51f4efe52e43dbcd6dc95b5554b3332957`
|
|
||||||
- Greenfield PRD v3 addenda: INV-B durable journal, INV-C visible failure diagnostics, INV-D supported fixture
|
|
||||||
- Binding doctrine: `/src/jarvis-brain/infra/fleet/FLEET-DOCTRINE.md`
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Publish grant/validate v1 caller contract for MB-BRAIN-01.
|
|
||||||
2. Add repo PRD requirements and preregister acceptance tests.
|
|
||||||
3. Implement explicit estate registry, secure current file-store adapter, durable operation journal/audit, provider transport, and terminal result types.
|
|
||||||
4. Implement `grant` and side-effect-free `validate`; then provision/wire/get/whoami/list/rotate/revoke/audit.
|
|
||||||
5. Make git and API resolver refusals identical and fail closed under fleet context.
|
|
||||||
6. Reconcile live HOMELAB seats through each subject credential's own `/user`; #1044 hold is lifted, and its fail-closed change carries the pre-registered mechanism evidence (resolver refusal marker, same-run marker positive control, confirmed-lane negative arm).
|
|
||||||
7. Run baseline/situational tests, independent code review and mandatory independent security review, CI on the exact head, then open the PR directly against `main` without closing issues or claiming Jarvis validation.
|
|
||||||
8. C1 merges first. After any base/head move, re-derive merge-base, commit set, diff, CI, reviews, and provider measurements from the replacement SHA.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
No explicit token cap supplied. Working cap: keep implementation in one package plus shipped framework resolver changes and required docs/tests; avoid unrelated wrapper defect fixes and VaultWarden redesign. Escalate only if a charter requirement is technically unsatisfiable.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- VaultWarden is out for the agent tier per the charter verdict; phase 1 governs the existing per-identity file store.
|
|
||||||
- Estate is explicit input and must match a configured host mapping; target host is never inferred from machine location.
|
|
||||||
- Grant authority and basic-auth provisioning material are delegated control-plane credentials, never caller bearer material and never CLI argument/output.
|
|
||||||
- `ok`, `refused`, `error`, and `indeterminate` are distinct machine outcomes. Security callers fail closed on all but `ok`, while retaining the semantic distinction.
|
|
||||||
- Gitea write-differential resolves the subject once and binds provider identity, repository permission, and receive-pack to the same in-memory credential handle. It adds a distinct provider-confirmed read-only-principal control plus the unauthenticated control, with no ref update. The live HOMELAB negative-control subject is `tl-mosaic`, verified read-only on `mosaicstack/stack`; code and contract remain principal-agnostic.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- [x] Mode/intake/core guides/skills/doctrine loaded.
|
|
||||||
- [x] Spec repository READ confirmed under be-coder-06 from provider object.
|
|
||||||
- [x] Target-branch conflict resolved by the trunk-only ruling; the lane was rebased from `next` onto `origin/main`.
|
|
||||||
- [x] Canonical remote PRD v3 addenda re-read at new head.
|
|
||||||
- [x] Required issues read via Mosaic wrapper.
|
|
||||||
- [x] Early grant/validate contract v1 published at `docs/credentials/GRANT-VALIDATE-CONTRACT.md`.
|
|
||||||
- [x] Contract v1.1 binds transport to the same resolved principal and adds a provider-confirmed read-only-principal control.
|
|
||||||
- [x] Contract v1.2 distinguishes provider outage, absent identity, and rejected credential.
|
|
||||||
- [x] Contract v1.3 positive-controlled anonymous visibility; subsequently withdrawn as unsound for private identities.
|
|
||||||
- [x] Contract v1.4 implements ruling (b): subject credential's own `/user`, no admin/inventory authority, no implemented `identity-not-found` path.
|
|
||||||
- [x] PRD update.
|
|
||||||
- [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests.
|
|
||||||
- [x] Implementation: validate, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, reverse registry, and fleet fail-closed resolver paths.
|
|
||||||
- [x] Review hardening: rotation returns visible open journals; team evidence records absent objects accurately; team scope is checked before/after under a host-qualified OS advisory lock with verified compensation; `wire` authenticates the exact seat/path/roster binding and preserves post-rename mutation semantics.
|
|
||||||
- [x] Author advisory remediation: provisioning rollback verifies provider/token-store/Tea state and cannot suppress Tea cleanup failure; Tea put/remove always unlink secret-bearing temporary files; unrelated hosts exit before Mosaic identity grammar; `cred get` persists issuance start before disclosure, writes through a short-write-safe loop, and preserves partial/applied indeterminate semantics under write/append/seal faults; provision/rotate/revoke share a fixed cross-state-root lifecycle lock for cooperating processes only (provider authority is the authorization boundary; generation preconditions are optimistic for cooperating mutators; hostile same-UID filesystem mutation is explicitly deferred); Tea pre-state is snapshotted/restored independently; final journal seal faults revert to open classification.
|
|
||||||
- [ ] Legacy-token estate binding is explicitly deferred: the legacy format contains no estate and the installed shell path has no distributed strict registry. Enabling refusal before registry distribution plus token-envelope migration would strand active legacy-only seats; governed envelopes already bind estate exactly.
|
|
||||||
- [ ] Hostile same-UID direct filesystem mutation is explicitly deferred to #1065. The finding is copied verbatim there; PR #1059 links it. Phase 1 claims only optimistic generation preconditions and advisory locking for cooperating mutators, with provider authority as the authorization boundary.
|
|
||||||
- [ ] Independent code/security approvals on the final exact head (Codex advisory iterations are not independent approval).
|
|
||||||
- [ ] Final exact-head CI and provider evidence.
|
|
||||||
|
|
||||||
## Tests and evidence
|
|
||||||
|
|
||||||
Baseline after workspace build: package typecheck passed; Vitest 81/81 files and 1,514/1,514 tests passed. The package shell suite reached a pre-existing tracked #973 Bash 5.2 BASH_LINENO incompatibility and exited 97 before wake tests; this is baseline, not introduced by MC-CRED.
|
|
||||||
|
|
||||||
Red-first evidence:
|
|
||||||
- principal-bound validate module absent → focused suite red;
|
|
||||||
- incremental v1.1 run: write-capable, identity-mismatched, and receive-pack-admitted read-only controls each returned `ok`, causing 3/13 tests to fail for the exact control defect; after the control checks, 13/13 passed;
|
|
||||||
- read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed;
|
|
||||||
- estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites.
|
|
||||||
|
|
||||||
Pre-remediation focused evidence: 80/80 across 11 credential/command suites; package lint, typecheck, formatting, and build were green. The advisory remediation adds thirty-five focused regression cases for rollback cleanup, Tea temporary-file cleanup, unsupported-host passthrough, and credential issuance partial-write/post-write-audit truthfulness; focused reruns are green (latest credential/command set: 109/109). Final uncommitted author advisories report code `approve` with no findings and security `none` with no findings; they remain advisory rather than independent approval. Full package Vitest reached 1,614 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and use atomic rename only for each explicitly scoped single-file replacement primitive; cross-system lifecycle completion uses verified compensation rather than an atomic-commit claim; grant mutation/read-back state is journaled.
|
|
||||||
|
|
||||||
Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation.
|
|
||||||
|
|
||||||
Live validation v1.4 (subject credential's own `/user`, no admin): population 13; CONFIRMED 8; CREDENTIAL-REJECTED 4 (`coder-mos1`, `coder-mos2`, `f10-coder`, `merge-gate`); MISMATCH 1 (`mos-admin` token authenticates as `Mos`); NOT-MEASURED 0. The four false v1.2 `identity-not-found` sealed journals remain immutable and are explicitly superseded by four sealed correction journals. Evidence: `/home/hermes/agent-work/be-coder-06/live-validation-v1.4/`.
|
|
||||||
|
|
||||||
Write differential for be-coder-06 passed with the configured read-only control and unauthenticated arm. Unit evidence proves the control arm invalidates validation when write-capable, identity-mismatched, or receive-pack-admitted.
|
|
||||||
|
|
||||||
## Successor review-remediation evidence
|
|
||||||
|
|
||||||
- Canonical-image CI portability: `/usr/bin/true` was changed only to `/bin/true`; the second-process `flock` acquisition assertion remains `status === 0` and passes in the focused lifecycle suite.
|
|
||||||
- Required red phase: five exact finding controls failed before implementation (journal one-byte/zero/oversized progress, recursively reordered Tea metadata, and lock-release failure). Review remediation added a sixth red control proving final-seal failure could compensate a later cooperating team mutation after lock release; a prototype-named Tea metadata control also failed before canonical object construction was hardened.
|
|
||||||
- Focused final: 120/120 passed across all 11 credential and credential-command files.
|
|
||||||
- Package lint and typecheck passed; dependency-aware workspace build passed 13/13 packages; repository format check and `git diff --check` passed; both git credential resolver shell regressions passed.
|
|
||||||
- Full local package Vitest: 1,624 passed / 3 failed. The failures are the same installed-version update banner emitted to stderr by three `cli-smoke.spec.ts` cases; prior canonical pipeline #2223 ran all 22 CLI-smoke cases green. Exact-head canonical CI remains required after push.
|
|
||||||
- First uncommitted advisories found a real post-release compensation race. Its red control observed one removal after lock release; the fix forbids compensation after successful release, and the control then passed without weakening an assertion.
|
|
||||||
- Final uncommitted advisories: code `approve` with no findings; security risk `none` with no findings. Codex could not execute tests in its read-only sandbox (EROFS), so these verdicts rely on review only; the writable author runs above are the test evidence and independent reviewers remain required.
|
|
||||||
|
|
||||||
## Risks/blockers
|
|
||||||
|
|
||||||
- Hostile same-UID direct filesystem mutation is outside phase-1 and requires a transactional service, broker/distinct identity, or equivalent non-bypassable primitive; lifecycle flock and generation preconditions cover cooperating `mosaic cred` mutators only. Track the explicit security deferral linked from PR #1059.
|
|
||||||
- The full CLI surface is broad; protect scope by sharing one provider/registry/journal core rather than per-command scripts.
|
|
||||||
- Gitea exact token-scope read-back may require delegated Basic Auth. If a bearer-only validation path cannot obtain an exact provider token object, return `indeterminate` rather than claim a scope.
|
|
||||||
- #1044 hold is LIFTED. The four least-privilege credentials are capability-confirmed and identity-not-measured, not dead. Fleet fail-closed paths now refuse with stable reason markers and never enter shared fallback under `MOSAIC_AGENT_NAME`; interactive callers retain explicit shared behavior. Runtime mismatch coverage remains limited to tokens holding `read:user`; future mints close identity binding at creation without widening seat scopes.
|
|
||||||
- C1 PR #1054 remains sequencing-prior and requires its own exact-head gates. MC-CRED PR #1059 must freeze only after a new advisory review, independent review, and exact-head CI.
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# #1050 — Installer P0–P9 state machine and red-first fixture
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement C1 from the canonical greenfield-install PRD v2: a transactional P0–P9 installer spine, a side-effect-free P0–P8 `--check`, and a lane-parametric Debian/glibc non-root from-zero fixture. The acceptance milestone is an attributable RED on the pre-C1 installer while preserving P3 PASS.
|
||||||
|
|
||||||
|
## Authority and scope
|
||||||
|
|
||||||
|
- Canonical requirements: `jason.woltje/jarvis-brain` `docs/plans/2026-08-04-greenfield-install-blockers-PRD-v2.md`. Currency was re-derived after compaction: authenticated fetch resolved `origin/main` to `cb23e5fbc8a282fa967b93d7a134fa48d11b4bb1`; the PRD and charters are byte-identical to the previously read remote copies.
|
||||||
|
- Tracking: `mosaicstack/stack#1050` on `git.mosaicstack.dev` (author read back as `be-coder-05`).
|
||||||
|
- Historical implementation base: `origin/next` `4df478cdd150fdf8d52ea109f02ade5d85017acd`. Delivery PR #1054 targets `main` under L0's trunk-only rule; `next` remains a non-merging integration lane.
|
||||||
|
- Out of scope: PATH, skills, headless wizard/identity, activation remediation, #869 wiring, RM-02, main promotion.
|
||||||
|
- `docs/TASKS.md` is orchestrator-single-writer and is not modified by this worker.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Pre-register the canonical phase/output/side-effect-free/fault-injection checks and observe RED against the base installer.
|
||||||
|
2. Commit the immutable red-first acceptance fixture before implementation.
|
||||||
|
3. Add the state-machine/journal/postcondition spine without repairing P4/P5/P8 symptoms.
|
||||||
|
4. Wire the expected-RED from-zero fixture into Woodpecker using Debian/glibc and a non-root target user.
|
||||||
|
5. Run shell/static baselines, situational container validation, code review, security review, then deliver through a PR to `next` under the coordinator-owned merge path.
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
- Working estimate: 32K reasoning/output tokens.
|
||||||
|
- Hard external cap: none stated.
|
||||||
|
- Adaptation: keep implementation in shell surfaces already in scope; no package dependency install unless repository gates require it.
|
||||||
|
|
||||||
|
## Pre-registered acceptance checks
|
||||||
|
|
||||||
|
| ID | Exact case | Expected pre-fix result |
|
||||||
|
|---|---|---|
|
||||||
|
| C1-R1 | `tools/e2e-install-test.sh --lane next` in a clean Debian 12 container as uid 1001 | non-zero; P3 PASS; P4 `NOT-MEASURED / UNDECLARED`; P5/P6/P8 FAIL with own reasons |
|
||||||
|
| C1-R2 | `tools/install-state-machine.test.sh` phase table case | RED because base installer does not enumerate canonical P0–P9 contracts |
|
||||||
|
| C1-R3 | side-effect-free `--check` case over a fingerprinted HOME | RED because base `--check` is version-only rather than P0–P8 predicates |
|
||||||
|
| C1-R4 | fault injection after each P2…P8 | RED because base installer has no injectable durable journal/rollback state |
|
||||||
|
| C1-R5 | Docker unavailable | base harness incorrectly exits 0; replacement must fail non-zero |
|
||||||
|
| C1-R6 | lane resolution | bare checkout is forbidden; fixture must pass `--next` and assert the resolved prerelease version |
|
||||||
|
| C1-R7 | same Debian fixture with `git` absent vs present | absent: P1 FAIL while legacy installer exits 0 and sync degrades; present: P1 PASS and observed store/runtime containment 101/101 |
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Charter, doctrine, delivery/CI/QA/docs guides read and re-anchored after compaction.
|
||||||
|
- [x] Canonical PRD v2/v3 addenda and charters read from fetched `origin/main`; numbering reconciles with the TL spec. No numbering conflict found. INV-B/C/D are binding and implemented without renumbering.
|
||||||
|
- [x] Target base reachability verified with `merge-base --is-ancestor`.
|
||||||
|
- [x] Issue #1050 created and provider author read back.
|
||||||
|
- [x] Initial RED captured; TL rejected P4's repo-root count as a false RED. Four populations disagree (framework payload 1, repo root 13, sync store 101 in the fixture, W-jarvis observation 7), so C1 now requires a checkout-free declared shipped-set artifact and reports P4 `NOT-MEASURED / UNDECLARED` until C5 supplies it.
|
||||||
|
- [x] P6 strengthens #869: the two dead enforcement hooks reproduce from zero on a clean broker-less container. C1 asserts the breach but neither wires nor unwires it.
|
||||||
|
- [x] P1 false pass identified from the P4 evidence row: `git` is absent from the Debian base and was undeclared even though skill sync shells out to it. C1 adds `git` to P1; the fixture matrix preserves absent/present controls. The prior claim that web1's missing runtime skills reproduce this greenfield mechanism is withdrawn by the TL and is not carried here.
|
||||||
|
- [x] Corrected RED transcript captured and reported, including the git-present/absent controls and strict P3 PASS.
|
||||||
|
- [x] State-machine implementation complete: private pre-mutation journal/snapshot, P0–P8 `--check`, P2–P8 fault seam, rollback, durable manifest/journal seal, action-status persistence, safe rollback roots, and stale-projection recovery.
|
||||||
|
- [x] Debian/glibc checkout fixture now packages the complete current checkout, verifies its digest in-container, and reaches the expected attributable RED without host inheritance. CI compares its exact final phase map/reasons to `tools/fixtures/greenfield-expected-red.tsv`; the fixture remains red while the detector job is green only on an exact match.
|
||||||
|
- [ ] Reviews complete. Reviews 80 (`rev-security-02`) and 81 (`rev-974`) requested changes at `3934e03f`; their eight non-overlapping detector findings are being remediated red-first. Current remediation adds canonical-image portability, absolute P3 CLI propagation, exact expected-RED schema/cardinality, passwd-HOME binding, created-path owner/mode policy, real-action P2–P8 fault injection, verified non-empty remote installer execution, and seeded secret-canary/redacted diagnostics. Both old verdicts become void when the remediation head moves and require fresh independent review.
|
||||||
|
- [x] Successor remediation for review 90 is RED-first and recorded in `docs/reports/verification/1050-successor-remediation/`: the manifest now binds the complete supported final P0 reason; P4 rejects an incomplete created-path walk instead of discarding `find` failure; and the TERM no-exit control is independent of filesystem enumeration order while retaining a proven RED mutation. Pipeline 2224's 32/2 result was a path-order-sensitive control, not evidence that the resume bug's premise became stale. The enumeration-class sweep additionally replaced order-dependent `find | head -1` source-root selection with a checked complete inventory requiring exactly one extracted root.
|
||||||
|
- [x] C1 fix round for reviews 92/93: Blocker B was completed first. RED was reproduced before implementation: a planted `{"status":"in-progress"}` record makes the complete real walk fail the suite at P2, while the same planted defect beneath a target-owned mode-0100 directory makes real `find` fail and the frozen suite falsely exit 0 with `installer next lane tests passed`. One shared helper now captures and checks the complete NUL-delimited population before testing absence across the primary and copied harness sites; grep no-match is distinct from read failure. The same attack child drives the full suite RED with a named enumeration error, while ordinary native and root/container runs pass. Blocker A then regenerated the exact installer sidecar; provider-fetch validation remains the required post-push bar. Working estimate: 16K tokens; no external hard cap; 60% context is the stop/report gate.
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- The deployed create wrappers do not expose `--dry-run`; identity preflight was performed through `pr-merge.sh --dry-run` on the same HOMELAB repo, which resolved `git.mosaicstack.dev` + `be-coder-05`. The issue create then fell back from tea to the API but provider read-back confirmed author `be-coder-05`.
|
||||||
|
- `next` is a non-merging integration lane; PR #1054 targets `main`. The old “pending promotion to main” caution dissolved when the base moved. #1050 remains open after merge and closes only after Jarvis validates the greenfield behavior.
|
||||||
|
- #869 must remain staged and inactive.
|
||||||
|
- Late sequencing input MB-BRAIN-01 is accommodated without implementation or renumbering: P2 covers installer distribution only; P5 owns requested credential capability; P7 leaves an ordered seam for credential-dependent resource provisioning after P5.
|
||||||
|
|
||||||
|
## Remediation review controls
|
||||||
|
|
||||||
|
- B1 RED: the next-lane harness failed immediately under `ci-base:latest` as root/musl; it now models uid 1001/glibc explicitly and uses Python tree fingerprints instead of GNU `find -printf`.
|
||||||
|
- B2 RED: framework/runtime linking consumed bare `mosaic` from PATH after P3 had committed an absolute path. The unified installer now exports/passes `MOSAIC_CLI_PATH`; the linker invokes that absolute artifact, and wizard auto-launch has no stale-PATH fallback.
|
||||||
|
- B3 RED: a one-row manifest (`exit=1`) certified any exit-1 log. Full-manifest validation now requires the exact three cases, one exit and P0–P9 row each, pinned require/forbid populations, and rejects malformed/duplicate/unknown rows; shrink is a negative control.
|
||||||
|
- B4 RED: uid 1001 with a passwd HOME different from ambient HOME produced P0 PASS. P0 now binds uid, username, passwd HOME and shell and explicitly rejects root and sudo-with-inherited-HOME controls.
|
||||||
|
- B5 RED: mode-0777 CLI, mode-0644 identity, and mode-0755 credential storage passed. P3/P4/P5 now apply target owner/group plus executable/shared/private policies; framework credential storage is created 0700.
|
||||||
|
- B6 RED: fault injection only wrote `.selftest-*` files. The synthetic path was removed; the P2–P8 matrix enters the normal action flow, proves an action observation occurred, injects after each real phase, and fingerprints rollback.
|
||||||
|
- B7 RED: an HTTP-200 empty body exits zero when piped to Bash. The fetched installer must now be non-empty, digest-equal to `tools/install.sh.sha256`, and that exact file is executed; failed/empty/mismatch controls are blocking and CI has a remote immutable-commit arm.
|
||||||
|
- B8 RED: raw combined command output was duplicated to terminal and `commands.log`. Both capture layers now redact before output/persistence; a seeded canary is positively emitted by the fake credential-capable registry and must remain absent from terminal, command log, npmrc, generated files and observed argv. The real greenfield fixture also scans those populations.
|
||||||
|
- Advisory code review findings are fixed: URL userinfo redaction now handles raw `@`, token-only and percent-encoded forms, repeated `:`, multiple URLs, Authorization/Basic, npm `_auth`, and Cookie headers in both capture layers; the real greenfield path positively emits its canary through `state_run_captured`; verified-fetch removes its temporary body after successful execution; and plaintext diagnostics exist only in process-substitution pipes rather than interruptible temporary files.
|
||||||
|
- Advisory security review's independent trust-root finding is **DEFERRED by canonical PRD v2 §3**, which explicitly excludes signed provenance. README now states precisely that the same-origin sidecar detects empty/corrupt/inconsistent publication but cannot authenticate against repository/server compromise; no stronger claim remains.
|
||||||
|
- The web1 no-manifest representativeness observation is recorded but intentionally not acted on: it is explicitly outside these eight blockers. This remediation does not weaken or otherwise change P9's manifest-presence assertion.
|
||||||
|
|
||||||
|
## Verification log
|
||||||
|
|
||||||
|
- `bash -n` and ShellCheck pass for all changed shell surfaces; `git diff --check` passes.
|
||||||
|
- `bash tools/install-state-machine.test.sh` passes, including exact P0–P8 rows, passwd-HOME/privilege discrimination, owner/group/mode attacks, persisted P4/P6 action failures, no synthetic fault implementation, unsafe/overlapping/symlink roots, and fatal journal initialization.
|
||||||
|
- `bash tools/install-next-lane.test.sh` passes inside `ci-base:latest`, including exact `@next` versions, immutable source fallback, source-build/archive-failure rollback, offline `--dev`, explicit refs, prerelease suffix mismatch, absolute P3 CLI propagation, secret redaction, real-action P2–P8 rollback, and stale projection recovery.
|
||||||
|
- Comparator controls pass for verdict drift, unexpected exit, manifest shrink, missing phases, duplicate rows, unknown cases, and unknown kinds. Verified-fetch controls pass for successful execution and failed/empty/digest-mismatch rejection.
|
||||||
|
- `bash tools/e2e-install-test.sh --lane next --source checkout --git present` returns the required expected RED in clean Debian/glibc as uid 1001: installer P0/P1/P2/P3/P7 PASS; P4/P5/P6/P8 and P9 blocking; no `Done.` claim; checkout archive digest pinned and current framework installer exercised. `tools/verify-greenfield-expected-red.sh` converts that expected detector result into a green CI assertion and fails on any unreviewed verdict drift.
|
||||||
|
- Earlier repository gates passed: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, upgrade manifest/rollback/durable-snapshot/migration suites, and focused `@mosaicstack/mosaic` tests with an isolated npm prefix. Full exact-remediation rerun is required before push.
|
||||||
|
- Review-93 RED evidence at frozen `378bc1a`: isolated positive-control full-suite exit `1` with `P2 left an in-progress transaction`; isolated permission-failure attack full-suite exit `0` with final `installer next lane tests passed`. No binary shadowing or PATH interception was used; the failure came from a real target-owned mode-0100 directory.
|
||||||
|
- Fix-round GREEN: `pnpm test:installer`, native next-lane, root/`ci-base:latest` next-lane, state-machine, verified-fetch, Bash syntax, ShellCheck, `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, and `git diff --check` pass. The direct attack child exits 1 and names `P2 fault-state enumeration failed`. All 81 Mosaic Vitest files / 1508 tests pass under an isolated npm prefix; the wider framework-shell chain reaches the pre-existing #973 Bash-line-number gate and exits 97, matching the known host-specific condition rather than this delta. Codex code review approved at 0.93 confidence with zero findings; Codex security review reported no risk at 0.96 confidence with zero findings.
|
||||||
|
- Sidecar RED-first documentation control from the repository root: `sha256sum -c tools/install.sh.sha256` exits 1 with `install.sh: FAILED open or read` because the sidecar path is relative to `tools/`. Sidecar GREEN with the command actually executed: `(cd tools && sha256sum -c install.sh.sha256)` exits 0 with `install.sh: OK`; the workflow's separate expected/actual comparison resolves `e59cb441a2f37ae9150f8eae470238e9d858a1816df93343d9784a6796676096`. This is not substituted for the required immutable provider-fetch arm at the pushed head.
|
||||||
|
|
||||||
|
## Round 3 — remote-arm downstream source binding
|
||||||
|
|
||||||
|
### Objective and constraints
|
||||||
|
|
||||||
|
- Tracking remains `mosaicstack/stack#1050`, delivery PR #1054, branch `feat/1050-install-state-machine-red-fixture`; `docs/TASKS.md` remains orchestrator-single-writer.
|
||||||
|
- Bind the remote arm's downstream stack framework/source archive to the same immutable `${CI_COMMIT_SHA}` as the digest-verified `install.sh`, while retaining `--next` and its exact resolved CLI/gateway lane-version assertion.
|
||||||
|
- RED first: the realised source commit/digest assertion must reject the existing stale `origin/next` substitution; GREEN must restore the exact expected `P6 FAIL` and pass the comparator. R7 then deletes the binding and requires RED again.
|
||||||
|
- Fenced out: the expected-RED manifest (including `P6=FAIL`), #869 activation, and #1068 digest-before-comparator sequencing.
|
||||||
|
- Push budget: one force-with-lease push pinned to provider head `ff3f0d29f1763bed44a60610d073036112e66b77`; run the queue guard first; do not poll CI after push.
|
||||||
|
- Working estimate: 14K reasoning/output tokens; no external hard token cap. Scope reduction order: reuse the existing local-source archive seam, add one realised-state assertion, avoid installer behavior changes.
|
||||||
|
|
||||||
|
### Corrected reference citation
|
||||||
|
|
||||||
|
The transferable reference is only **the container-image acquisition path in `fa-install.sh`**: resolve mutable input once to `Docker-Content-Digest`, fail closed if unresolved, render `@sha256`, then verify running images against the resolved digest. It is not a claim about USC's installer as a whole; the sibling root bundle extraction in `Install-FieldAgentOnPanel.ps1:82` is unpinned. The applicable structure is `RESOLVE-ONCE -> PIN -> FAIL-CLOSED ON RESOLUTION -> VERIFY-AFTER`, applied per acquisition path.
|
||||||
|
|
||||||
|
### Remote-arm acquisition-path census before the fix
|
||||||
|
|
||||||
|
Code-path enumeration only; execution of the complete list is **NOT MEASURED** in this round yet. Logical payload paths are counted once even when one command is invoked repeatedly or installs a dependency graph.
|
||||||
|
|
||||||
|
| # | Acquisition path taken by the arm | Pre-fix binding state | Scope disposition |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Woodpecker checkout containing the workflow, fixture, sidecar, and comparator | CI commit checkout (commit-bound by runner contract) | existing arm substrate |
|
||||||
|
| 2 | `node:22-bookworm-slim` step image | mutable image tag | unpinned sibling; outside this fix fence |
|
||||||
|
| 3 | Debian package set acquired by `apt-get update/install` | repository-current, no package versions/snapshot | unpinned sibling; outside this fix fence |
|
||||||
|
| 4 | remote `tools/install.sh` | exact `${CI_COMMIT_SHA}` URL plus checked SHA-256 | in scope; already bound first hop |
|
||||||
|
| 5 | `@mosaicstack/mosaic@next` CLI package graph | mutable lane resolved to an exact top-level version, exact version installed, realised top-level version verified | lane assertion; must remain |
|
||||||
|
| 6 | `@mosaicstack/gateway@next` package graph | later re-resolution wins and is verified against itself; drift from the first value can be silently accepted | partially constrained TOCTOU sibling; outside this fix |
|
||||||
|
| 7 | `mosaicstack/stack` framework/source archive consumed by `ensure_monorepo` | `next` resolves once to `4df478cdd150fdf8d52ea109f02ade5d85017acd`, not the arm's CI commit | **in-scope defect; bind to CI commit** |
|
||||||
|
| 8 | sequential-thinking MCP package warmed via unversioned `npx -y @modelcontextprotocol/server-sequential-thinking` | mutable package resolution | unpinned sibling; outside this fix fence |
|
||||||
|
| 9 | Excalidraw npm dependency graph | shipped `package-lock.json` exact versions + registry integrity hashes; `npm install` consumes the lock | already lock/integrity-bound, separate from CI-source identity |
|
||||||
|
| 10 | canonical skills repo cloned by `mosaic-sync-skills` | mutable default branch of `mosaic/agent-skills.git` | unpinned sibling; outside this fix fence |
|
||||||
|
|
||||||
|
Pre-fix mechanism breakdown (do not blend unlike strengths): **2 / 10 intended-CI-commit-bound** (#1 checkout, #4 installer); **1 / 10 resolve-then-pin with comparison to the authoritative value** (#5 CLI); **1 / 10 partially constrained because verify-after compares #6 gateway to its later re-resolution rather than an authoritative pin**; **1 / 10 lockfile+integrity-bound** (#9 Excalidraw); **1 / 10 internally pinned to an immutable commit+digest but bound to the wrong arm identity** (#7 framework at `4df478cd`); **4 / 10 unpinned siblings** (#2, #3, #8, #10). The in-scope same-CI stack payload ratio is **1 / 2** (`install.sh` yes; framework/source no).
|
||||||
|
|
||||||
|
Resolution-failure behavior is separately stated rather than hidden in the counts. For #5/#6, a metadata-resolution failure during P1 blocks before mutation. Later package metadata/install failure enters the documented source fallback, but that fallback consumes the immutable stack archive already resolved in P2; failure to resolve or fetch that source is fatal. For #5, the P2 CLI value is authoritative and a later realised mismatch blocks P3 and rolls back. For #6, no authoritative P2 gateway value persists; a later same-suffix gateway drift can be silently accepted because the later value is compared with itself. Changing that TOCTOU path is outside this fix. The new #7 path accepts the 40-hex CI commit directly, fetches only its exact commit URL, rejects failed/empty acquisition, computes and passes the exact body's digest through the existing local-source seam, and rejects any realised manifest commit/digest mismatch without falling back to `next`.
|
||||||
|
|
||||||
|
Denominator corrections were sent to and accepted by the lane lead before implementation. Four expressly unpinned siblings make `7/10` impossible. Post-fix, the primary mechanism breakdown is **fully constrained 5 / 10** (#1, #4, #5, #7, #9), **partially constrained 1 / 10** (#6), and **unpinned 4 / 10** (#2, #3, #8, #10). The meaningful repaired ratio is **2 / 2 same-CI stack payload hops**. The fix does not increase coverage; it moves #7 from wrong identity to intended identity. Every census revision moved assurance downward under additional questioning, never upward, so the census is a lower bound on defects and an upper bound on assurance.
|
||||||
|
|
||||||
|
### Implementation plan
|
||||||
|
|
||||||
|
1. Add the realised source commit+archive-digest assertion and pass the expected immutable identity through the remote fixture; run the current stale-`next` mechanism and capture RED before changing acquisition.
|
||||||
|
2. Fetch the stack archive from the exact commit URL once, fail closed on failed/empty acquisition, compute its digest, pass that exact body through the installer's existing local-source archive seam, and verify the manifest's realised `sourceCommit` and `sourceSha256` against those values.
|
||||||
|
3. Run exact-source GREEN: require `P6 FAIL` values and a passing `next-git-present` comparator; run focused installer/baseline gates.
|
||||||
|
4. R7: delete the binding in a temporary mutant, run the same assertion to RED, then restore and re-run GREEN.
|
||||||
|
5. Independent code/security review, commit with command-scoped identity, queue guard, one force-with-lease push pinned to `ff3f0d29`; stop without CI polling and report `believed-fixed, pending jarvis validation`.
|
||||||
|
|
||||||
|
### Executed outcome
|
||||||
|
|
||||||
|
- RED-first stale substitution: fixture `rc=1`; realised source `4df478cdd150fdf8d52ea109f02ade5d85017acd` / `3e6d831efe13c3b2c0501507099d4a566af5abf877dacf85e5d7e4284d35e5c7` rejected against expected `ff3f0d29f1763bed44a60610d073036112e66b77` / `944c6db1b01b23c83169e6dc83e0d31262b1d24a2825270441745eb65c269c25`; comparator `rc=1`, 8/19 mismatches.
|
||||||
|
- Exact-source reproducibility, N=5 identical final-tree executions: every run had fixture `rc=1`, `@mosaicstack/mosaic@next=0.0.50-next.2207`, P3 PASS, realised source `ff3f0d29…` / `944c6db1…`, SOURCE-CONTROL PASS, `installer_exit=1`, `done_claims=0`, P6 FAIL, and P9 FAIL. Comparator `rc=0` occurred 4/5 (19/19 checks; action-failure reason); comparator `rc=1` occurred 1/5 (18/19; dead hooks active count `2`). The rate is the finding; no comparator verdict is claimed.
|
||||||
|
- Timings/load: runs 1–2 NOT MEASURED; run 3 rc1 elapsed 912s, load 7.79/7.51/8.93 -> 12.28/14.82/11.79; run 4 rc0 elapsed 938s, load 12.28/14.82/11.79 -> 9.84/15.18/13.83; run 5 rc0 elapsed 954s, load 9.84/15.18/13.83 -> 3.94/4.76/8.18. Load does not explain this sample monotonically.
|
||||||
|
- The `P6=FAIL` row remains untouched; P6 failed 5/5 while its reason signal varied. No comparator widening was performed. Checkout-source control independently exhibited the rc1/dead-hooks outcome, but its reproducibility was not separately measured.
|
||||||
|
- #869 out-of-scope finding: current `defaultSupervisorProbe` checks bundled supervisor artifact presence and a resolvable socket path, not socket existence. The 2000ms capability-probe timeout is a code-read hypothesis for variance, NOT MEASURED as causal.
|
||||||
|
- Final-tree R7 deleted the three local-source binding exports temporarily. The realised source reverted to `4df478cd…` / `3e6d831…`, SOURCE-CONTROL failed against `ff3f0d29…` / `944c6db…`, and comparator `rc=1` with 8/19 mismatches. The subject file SHA-256 was `a93113565aa69f2c6f3d792b78251021bb3bbe3f7813d5fed547ab0099fa3b98` before mutation and after restoration.
|
||||||
|
- Trust boundary: the exact-commit URL trusts the configured repository provider's authenticated commit-to-archive mapping. The computed digest pins transfer/consumption but does not authenticate against repository/TLS compromise; signed provenance remains the canonical PRD v2 §3 deferral. Initial Codex security review retained this as HIGH/CWE-494; no stronger claim or out-of-scope signing change was made.
|
||||||
|
- Final baselines: Bash syntax, ShellCheck, `pnpm test:installer`, `pnpm typecheck` (45/45), `pnpm lint` (25/25), `pnpm format:check`, and `git diff --check` pass. Codex code review APPROVE confidence 0.92 with zero findings; after explicit trust-boundary documentation, security re-review risk NONE confidence 0.96 with zero findings. The initial HIGH trust-root finding remains recorded as the signed-provenance deferral.
|
||||||
|
- Full evidence and named paths: `docs/reports/verification/1050-c1-fix-round/09-round3-source-binding.txt`.
|
||||||
|
|
||||||
|
## Round 4 — pipeline case-coverage denominator
|
||||||
|
|
||||||
|
- Pipeline 2242 at `0e2eef1c` superseded the lane lead's earlier pipeline-2229 ruling: requirements 1–3 were already satisfied. The temporary local P6-consumer edit started while that ruling was in flight was restored; `tools/e2e-install-test.sh`, the expected-RED manifest, the per-case verifier, and #869 remain unchanged in the final tree.
|
||||||
|
- Requirement 5 adds a pipeline-level instrument above the three per-case invocations. Expected names are derived from the manifest, markers are scoped by pipeline+workflow run, and each marker is written only after that exact per-case verifier succeeds. Every producer depends on initialization; the final step depends on the complete case/contract matrix, runs after success or failure, and requires exact expected/actual set equality rather than count equality.
|
||||||
|
- Measured firing controls: skipped arm `3/2` rc1; count inflation `3/3` rc1 with one missing and one unexpected name; stale re-initialized run `3/0` rc1; exact set `3/3` rc0; future manifest case `4/3` rc1. Codex review found and blocked two independent defects: first the missing `depends_on` graph, then comment lines being parsed as case names. Both were accepted; dependency edges are regression-asserted, blank/comments are excluded, and the focused test now consumes the production manifest directly. Final Codex code re-review APPROVE confidence 0.94 and security re-review risk NONE confidence 0.96, both with zero findings. Woodpecker strict lint, Bash syntax, ShellCheck, focused/full installer tests, typecheck, lint, Prettier, and diff check pass.
|
||||||
|
- Full evidence: `docs/reports/verification/1050-c1-fix-round/10-round4-case-coverage.txt`.
|
||||||
|
|
||||||
|
## Round 5 — arm coverage and checkout purity
|
||||||
|
|
||||||
|
- Reviews 110/111 identified two blockers at `f33bd0da`: the three-case set could not represent the fourth `greenfield-remote-installer-contract` arm, and the root `.greenfield-case-state` directory was included in checkout fixture archives. The expected-RED manifest, per-case verifier, detector, and #869 remain fenced.
|
||||||
|
- RED controls: with all three cases complete and no remote-arm identity, the old gate returned rc0 at `cases_defined=3 cases_executed=3`; the exact fixture tar selector archived `.greenfield-case-state/remote-arm.ran` once.
|
||||||
|
- The checker is generalized across exact `cases` and `arms` dimensions. A new explicit four-arm declaration includes the remote contract; all four arms mark only after successful per-case verification. With the remote omitted, cases remain 3/3 rc0 while arms report 4/3 rc1 and name `greenfield-remote-installer-contract` as missing.
|
||||||
|
- State moved beneath `.mosaic-test-work/greenfield-execution-coverage`, which the existing checkout selector excludes. The regression control proves a non-excluded root marker is archived while no `.mosaic-test-work` path is archived.
|
||||||
|
- Final review: Codex code APPROVE confidence 0.93 and security risk NONE confidence 0.96, both with zero findings. Full installer tests, typecheck 45/45, lint 25/25, format, Bash syntax, ShellCheck, Woodpecker strict lint, and diff check pass with 11G free before/after.
|
||||||
|
- Full evidence: `docs/reports/verification/1050-c1-fix-round/11-round5-arm-coverage-and-purity.txt`.
|
||||||
|
|
||||||
|
## Round 6 — executable aggregation and production archive binding
|
||||||
|
|
||||||
|
- Reviews 114/115 independently reproduced the same caller-level mutant at `df705828`: ignoring `arms_status` in the workflow still left the helper-bounded focused suite green. The production OR was correct and was not rewritten; its aggregation moved byte-for-byte into `verify-greenfield-execution-coverage-gate.sh`, which the workflow invokes directly.
|
||||||
|
- The focused suite now exercises that exact helper across the complete truth table: cases PASS/arms FAIL RED; cases FAIL/arms PASS RED; both FAIL emits both outputs and is RED; both PASS GREEN. Local ignore-arms, ignore-cases, and always-RED mutants each make the suite RED.
|
||||||
|
- Review `rev-974` separately proved that deleting only the production `e2e-install-test.sh` `.mosaic-test-work` tar exclusion left the copied-selector control green. The control now structurally binds its semantic archive test to the production checkout-archive command; the delete-production-binding mutant is RED. The fenced production installer remains byte-unchanged.
|
||||||
|
- Final review: Codex code APPROVE confidence 0.94 and security risk NONE confidence 0.97, both with zero findings. Full installer tests, typecheck 45/45, lint 25/25, format, Bash syntax, ShellCheck, Woodpecker strict lint, focused truth-table and four mutant controls, and diff check pass with 11G free before validation.
|
||||||
|
- Full evidence: `docs/reports/verification/1050-c1-fix-round/12-round6-caller-coupling-and-archive-binding.txt`.
|
||||||
|
|
||||||
|
## Round 7 — canonical Alpine/BusyBox portability
|
||||||
|
|
||||||
|
- Review 119 found the exact-head focused suite RED in the canonical Alpine `ci-base`: GNU `find -printf` is unsupported by BusyBox 1.37. The same script remained green in the Debian greenfield workflow, so greenfield success alone did not prove canonical runtime compatibility.
|
||||||
|
- Local canonical-image RED was reproduced at rc1 before remediation. Marker inventory now uses Bash nullglob/dotglob and parameter expansion, checks state-directory readability, preserves the prior regular-file/non-symlink boundary, and retains sorted exact-set comparison without GNU-only flags.
|
||||||
|
- The focused suite is green both locally and inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`. Canonical pipeline evidence remains pending the replacement frozen-head push; no stale pipeline is inherited.
|
||||||
|
- Final review: Codex code APPROVE confidence 0.96 and security risk NONE confidence 0.97, both with zero findings. Full installer tests, typecheck 45/45, lint 25/25, format, Bash syntax, ShellCheck, Woodpecker strict lint, dual-runtime focused tests, retained Round-6 mutants, and diff check pass with 11G free.
|
||||||
|
- Full evidence: `docs/reports/verification/1050-c1-fix-round/13-round7-busybox-portability.txt`.
|
||||||
|
|
||||||
|
## Round 8 — pipefail-safe expected-set membership
|
||||||
|
|
||||||
|
- Review 122 found `expected_names | grep -Fxq` could falsely reject a valid early-sorted marker: `grep -q` closes after its match, the upstream sorter can exit 141, and `pipefail` selects that producer failure. Independent canonical-Alpine stress measured failures at the live four-arm manifest size, so no safe small-set threshold is claimed.
|
||||||
|
- A deterministic 20,001-entry real-script mark control makes the unfixed canonical-Alpine path RED with `case is not in the expected set: a-target`. The mark path now materializes and validates the complete expected-name snapshot before applying `grep -q` via a here-string, eliminating the producer/consumer pipe while retaining fail-closed producer errors.
|
||||||
|
- The focused suite is green locally and in canonical Alpine after remediation. A separate 1,000-mark canonical-Alpine stress against the live four-arm production manifest recorded zero false failures.
|
||||||
|
- Final review: Codex code APPROVE confidence 0.96 and security risk NONE confidence 0.98, both with zero findings. Full installer tests, typecheck 45/45, lint 25/25, format, Bash syntax, ShellCheck, Woodpecker strict lint, canonical deterministic/stress controls, retained Round-6 mutants, and diff check pass with 11G free.
|
||||||
|
- Full evidence: `docs/reports/verification/1050-c1-fix-round/14-round8-pipefail-membership.txt`.
|
||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"clean:generated": "node scripts/clean-generated.mjs",
|
"clean:generated": "node scripts/clean-generated.mjs",
|
||||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||||
"test:checkout": "node --test scripts/*.test.mjs",
|
"test:checkout": "node --test scripts/*.test.mjs",
|
||||||
"test": "pnpm test:checkout && turbo run test",
|
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
||||||
|
"test:installer": "bash tools/install-state-machine.test.sh && bash tools/install-next-lane.test.sh && bash tools/verify-greenfield-expected-red.test.sh && bash tools/verify-greenfield-execution-coverage.test.sh && bash tools/verified-installer-fetch.test.sh",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||||
"prepare": "node scripts/install-hooks.mjs"
|
"prepare": "node scripts/install-hooks.mjs"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ done
|
|||||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||||
# shellcheck source=tools/_lib/manifest.sh
|
# shellcheck source=tools/_lib/manifest.sh
|
||||||
|
# shellcheck disable=SC1091 # Dynamic SOURCE_DIR; the path is validated by set -e.
|
||||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||||
|
|
||||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||||
@@ -222,12 +223,14 @@ prune_durable_snapshots() {
|
|||||||
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
||||||
list="$(mktemp)"
|
list="$(mktemp)"
|
||||||
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
||||||
|
warn "Backup pruning skipped; policy: retention cleanup is optional and a failed enumeration must preserve every existing recovery snapshot."
|
||||||
rm -f "$list"; return 0
|
rm -f "$list"; return 0
|
||||||
fi
|
fi
|
||||||
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
||||||
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
||||||
# than risk pruning in an undefined order.
|
# than risk pruning in an undefined order.
|
||||||
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
||||||
|
warn "Backup pruning skipped; policy: ordering failure preserves all snapshots rather than risking deletion in an undefined order."
|
||||||
rm -f "$list"; return 0
|
rm -f "$list"; return 0
|
||||||
fi
|
fi
|
||||||
while IFS= read -r d; do
|
while IFS= read -r d; do
|
||||||
@@ -266,7 +269,11 @@ make_durable_snapshot() {
|
|||||||
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
chmod 700 "$root" 2>/dev/null || true
|
if ! chmod 700 "$root"; then
|
||||||
|
umask "$old_umask"
|
||||||
|
warn "Durable snapshot skipped: backup root permissions could not be made private; policy: never write operator data to an insufficiently protected location."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
dir="$root/pre-update-$ts"
|
dir="$root/pre-update-$ts"
|
||||||
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
||||||
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
||||||
@@ -281,7 +288,10 @@ make_durable_snapshot() {
|
|||||||
if ! enumerate_operator_files "$list"; then
|
if ! enumerate_operator_files "$list"; then
|
||||||
umask "$old_umask"
|
umask "$old_umask"
|
||||||
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
||||||
rm -f "$list"; rmdir "$dir" 2>/dev/null || true
|
rm -f "$list"
|
||||||
|
if ! rmdir "$dir"; then
|
||||||
|
warn "Durable snapshot cleanup left $dir in place; policy: preserve unexpected content rather than deleting it recursively."
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
while IFS= read -r -d '' rel; do
|
while IFS= read -r -d '' rel; do
|
||||||
@@ -292,12 +302,18 @@ make_durable_snapshot() {
|
|||||||
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
chmod 600 "$dst" 2>/dev/null || true
|
if ! chmod 600 "$dst"; then
|
||||||
|
rm -f "$dst"
|
||||||
|
warn "Durable snapshot: copied '$rel' could not be made private and was removed; policy: do not retain an insecure recovery copy."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
count=$((count + 1))
|
count=$((count + 1))
|
||||||
done < "$list"
|
done < "$list"
|
||||||
rm -f "$list"
|
rm -f "$list"
|
||||||
# Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
|
# Tighten every dir the copy created (mkdir -p already honored umask 077).
|
||||||
find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
|
if ! find "$dir" -type d -exec chmod 700 {} +; then
|
||||||
|
warn "Durable snapshot directory permission recheck failed; policy: continue because every directory was created under umask 077, while retaining the diagnostic."
|
||||||
|
fi
|
||||||
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
||||||
DURABLE_SNAPSHOT_DIR="$dir"
|
DURABLE_SNAPSHOT_DIR="$dir"
|
||||||
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
||||||
@@ -344,7 +360,9 @@ verify_operator_surface() {
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if cp "$snap" "$cur"; then
|
if cp "$snap" "$cur"; then
|
||||||
chmod 600 "$cur" 2>/dev/null || true
|
if ! chmod 600 "$cur"; then
|
||||||
|
warn "Operator file '$rel' was restored but its mode could not be tightened to 0600; policy: preserve recovered content and require manual permission repair."
|
||||||
|
fi
|
||||||
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
||||||
healed=$((healed + 1))
|
healed=$((healed + 1))
|
||||||
else
|
else
|
||||||
@@ -535,7 +553,7 @@ sync_framework_keep() {
|
|||||||
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
||||||
# the "directory not empty" races we tolerate are ignored via -delete's own
|
# the "directory not empty" races we tolerate are ignored via -delete's own
|
||||||
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
||||||
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
|
if ! find "$dst/$root" -type d -empty -delete; then
|
||||||
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
||||||
fi
|
fi
|
||||||
done < <(manifest_subtree_roots)
|
done < <(manifest_subtree_roots)
|
||||||
@@ -581,7 +599,7 @@ run_migrations() {
|
|||||||
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
||||||
if [[ -d "$TARGET_DIR/bin" ]]; then
|
if [[ -d "$TARGET_DIR/bin" ]]; then
|
||||||
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
||||||
rm -rf "$TARGET_DIR/bin"
|
rm -rf "${TARGET_DIR:?}/bin"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Remove old mosaic PATH entry from shell profiles
|
# Remove old mosaic PATH entry from shell profiles
|
||||||
@@ -692,9 +710,11 @@ trap 'restore_snapshot; exit 1' ERR INT TERM
|
|||||||
|
|
||||||
sync_framework
|
sync_framework
|
||||||
|
|
||||||
# Ensure persistent directories exist
|
# Ensure persistent directories exist. Credentials are private material and
|
||||||
|
# must never inherit a permissive umask/default mode.
|
||||||
mkdir -p "$TARGET_DIR/memory"
|
mkdir -p "$TARGET_DIR/memory"
|
||||||
mkdir -p "$TARGET_DIR/credentials"
|
mkdir -p "$TARGET_DIR/credentials"
|
||||||
|
chmod 0700 "$TARGET_DIR/credentials"
|
||||||
|
|
||||||
# Reconcile contract files from defaults/ into the framework root: framework-owned
|
# Reconcile contract files from defaults/ into the framework root: framework-owned
|
||||||
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
|
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
|
||||||
@@ -706,13 +726,23 @@ mkdir -p "$TARGET_DIR/credentials"
|
|||||||
# by `mosaic init` from templates with user-supplied values.
|
# by `mosaic init` from templates with user-supplied values.
|
||||||
reconcile_framework_files
|
reconcile_framework_files
|
||||||
|
|
||||||
# Ensure tool scripts are executable
|
# Ensure tool scripts are executable. These are P4 postconditions, not
|
||||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
# best-effort cleanup: a chmod failure leaves shipped tools unloadable.
|
||||||
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
if ! find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} +; then
|
||||||
|
fail "Could not mark shipped shell tools executable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} +; then
|
||||||
|
fail "Could not mark shipped runtime scripts executable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
||||||
# suffix — git resolves credential helpers by exact name/path, not extension —
|
# suffix — git resolves credential helpers by exact name/path, not extension.
|
||||||
# so the *.sh glob above does not cover it; chmod it explicitly.
|
if [[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] \
|
||||||
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
|
&& ! chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic"; then
|
||||||
|
fail "Could not mark git-credential-mosaic executable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
ok "Framework synced to $TARGET_DIR"
|
ok "Framework synced to $TARGET_DIR"
|
||||||
|
|
||||||
@@ -739,49 +769,162 @@ step "Post-install tasks"
|
|||||||
|
|
||||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||||
|
|
||||||
|
# Capture every fallible post-install command. A failure's text is surfaced and
|
||||||
|
# also appended to the parent transaction's private command log. Failure to
|
||||||
|
# write that log is fatal: continuing would recreate the false-clean diagnosis
|
||||||
|
# INV-C forbids.
|
||||||
|
record_phase_outcome() {
|
||||||
|
local phase="$1" status="$2" reason="$3"
|
||||||
|
[[ -n "${MOSAIC_INSTALL_PHASE_STATUS_FILE:-}" ]] || return 0
|
||||||
|
if ! printf '%s\t%s\t%s\n' "$phase" "$status" "$reason" >> "$MOSAIC_INSTALL_PHASE_STATUS_FILE" \
|
||||||
|
|| ! sync "$MOSAIC_INSTALL_PHASE_STATUS_FILE"; then
|
||||||
|
fail "Could not durably record $phase action outcome for the parent transaction."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
redact_install_stream() {
|
||||||
|
# Keep this bootstrap copy behaviorally identical to tools/install.sh's
|
||||||
|
# state_redact_stream; neither installer can assume the other is installed.
|
||||||
|
python3 /dev/fd/3 3<<'PY'
|
||||||
|
import os, re, sys
|
||||||
|
text = sys.stdin.read()
|
||||||
|
secret_name = re.compile(r"(?:TOKEN|PASSWORD|PASSWD|SECRET|API_KEY|AUTH|CREDENTIAL|CANARY)", re.I)
|
||||||
|
secrets = {value for name, value in os.environ.items() if secret_name.search(name) and len(value) >= 4}
|
||||||
|
for value in sorted(secrets, key=len, reverse=True):
|
||||||
|
text = text.replace(value, "[REDACTED]")
|
||||||
|
patterns = (
|
||||||
|
(re.compile(r"(?im)^(\s*(?:proxy-)?authorization\s*:\s*)[^\r\n]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?im)^(\s*(?:set-)?cookie\s*:\s*)[^\r\n]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)(Bearer\s+)[^\s'\"]+"), r"\1[REDACTED]"),
|
||||||
|
(re.compile(r"(?i)((?:[_-]?auth(?:Token)?|token|password|passwd|secret|api[_-]?key)\s*[=:]\s*)[^\s'\"]+"), r"\1[REDACTED]"),
|
||||||
|
)
|
||||||
|
for pattern, replacement in patterns:
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
url_pattern = re.compile(r"https?://[^\s'\"<>]+", re.I)
|
||||||
|
def redact_url(match):
|
||||||
|
url = match.group(0)
|
||||||
|
scheme_end = url.find("://") + 3
|
||||||
|
authority_end = len(url)
|
||||||
|
for separator in "/?#":
|
||||||
|
position = url.find(separator, scheme_end)
|
||||||
|
if position != -1:
|
||||||
|
authority_end = min(authority_end, position)
|
||||||
|
authority = url[scheme_end:authority_end]
|
||||||
|
at = authority.rfind("@")
|
||||||
|
if at != -1:
|
||||||
|
return url[:scheme_end] + "[REDACTED]@" + authority[at + 1:] + url[authority_end:]
|
||||||
|
return url
|
||||||
|
sys.stdout.write(url_pattern.sub(redact_url, text))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
run_captured() {
|
||||||
|
local label="$1" redacted redactor_pid capture_fd status=0 redact_status=0
|
||||||
|
shift
|
||||||
|
redacted="$(mktemp "${TMPDIR:-/tmp}/mosaic-post-redacted.XXXXXX")"
|
||||||
|
chmod 0600 "$redacted" || { rm -f "$redacted"; exit 1; }
|
||||||
|
# Preserve in-shell command behavior without ever staging plaintext output on
|
||||||
|
# disk. Process substitution carries raw bytes only through a pipe.
|
||||||
|
exec {capture_fd}> >(redact_install_stream > "$redacted")
|
||||||
|
redactor_pid=$!
|
||||||
|
set +e
|
||||||
|
"$@" >&"$capture_fd" 2>&1
|
||||||
|
status=$?
|
||||||
|
exec {capture_fd}>&-
|
||||||
|
wait "$redactor_pid"
|
||||||
|
redact_status=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$redact_status" -ne 0 ]]; then
|
||||||
|
rm -f "$redacted"
|
||||||
|
fail "Could not redact '$label' diagnostics; refusing to expose or persist raw output."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "${MOSAIC_INSTALL_COMMAND_LOG:-}" ]]; then
|
||||||
|
if ! { printf '\n=== %s (exit=%s) ===\n' "$label" "$status"; cat "$redacted"; } >> "$MOSAIC_INSTALL_COMMAND_LOG" \
|
||||||
|
|| ! sync "$MOSAIC_INSTALL_COMMAND_LOG"; then
|
||||||
|
cat "$redacted" >&2
|
||||||
|
rm -f "$redacted"
|
||||||
|
fail "Could not durably append '$label' diagnostics to the install command log."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$status" -ne 0 ]]; then cat "$redacted" >&2; fi
|
||||||
|
rm -f "$redacted"
|
||||||
|
return "$status"
|
||||||
|
}
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
||||||
link_args=()
|
link_args=()
|
||||||
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
||||||
# stdout is suppressed as before, but stderr is left connected: the
|
if run_captured "runtime asset linking" "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}"; then
|
||||||
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
|
record_phase_outcome P6 committed "runtime asset linker exited zero"
|
||||||
# the operator, not be swallowed silently.
|
|
||||||
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
|
|
||||||
ok "Runtime assets linked"
|
ok "Runtime assets linked"
|
||||||
else
|
else
|
||||||
warn "Runtime asset linking failed (non-fatal) — see message above for details."
|
record_phase_outcome P6 failed "runtime asset linker exited non-zero"
|
||||||
|
warn "Runtime asset linking did not commit; policy: continue only to enumerate all phase diagnostics, while P6/P9 remain blocking."
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
record_phase_outcome P6 failed "required runtime asset linker is missing or not executable"
|
||||||
|
warn "Runtime asset linking was not attempted; policy: a missing required linker remains a blocking P6/P9 failure."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
|
||||||
if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then
|
if run_captured "sequential-thinking setup" "$SCRIPTS/mosaic-ensure-sequential-thinking"; then
|
||||||
ok "sequential-thinking MCP configured"
|
ok "sequential-thinking MCP configured"
|
||||||
|
elif [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
||||||
|
record_phase_outcome P6 failed "sequential-thinking setup failed under diagnostic-continuation compatibility mode"
|
||||||
|
warn "sequential-thinking setup did not commit; policy: the unified installer compatibility flag allows diagnostic continuation, while P6/P9 remain blocking."
|
||||||
else
|
else
|
||||||
if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
|
fail "sequential-thinking MCP setup failed (hard requirement)."
|
||||||
warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
|
exit 1
|
||||||
else
|
|
||||||
fail "sequential-thinking MCP setup failed (hard requirement)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
|
||||||
"$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)"
|
if run_captured "excalidraw setup" "$SCRIPTS/mosaic-ensure-excalidraw"; then
|
||||||
|
ok "excalidraw MCP configured"
|
||||||
|
else
|
||||||
|
warn "excalidraw setup did not commit; policy: optional integration failure is retained in the journal and does not define core install readiness."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" == "1" ]]; then
|
||||||
"$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)"
|
record_phase_outcome P4 failed "required skills sync explicitly skipped"
|
||||||
|
warn "Skills sync was skipped; policy: diagnostic continuation is allowed, but P4/P9 cannot certify an incomplete requested framework install."
|
||||||
|
elif [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
|
||||||
|
if run_captured "skills sync" "$SCRIPTS/mosaic-sync-skills"; then
|
||||||
|
record_phase_outcome P4 committed "skills sync exited zero"
|
||||||
|
ok "Skills synced"
|
||||||
|
else
|
||||||
|
record_phase_outcome P4 failed "skills sync exited non-zero"
|
||||||
|
warn "Skills sync did not commit; policy: continue to collect P4 diagnostics, but P4/P9 must not certify the install."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
record_phase_outcome P4 failed "required skills sync command is missing or not executable"
|
||||||
|
warn "Skills sync was not attempted; policy: a missing required sync command remains a blocking P4/P9 failure."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
|
||||||
"$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)"
|
if run_captured "local skills migration" "$SCRIPTS/mosaic-migrate-local-skills" --apply; then
|
||||||
|
ok "Local skills migrated"
|
||||||
|
else
|
||||||
|
record_phase_outcome P4 failed "local skills migration exited non-zero"
|
||||||
|
warn "Local skill migration did not commit; policy: preserve user content and continue diagnostics, while P4/P9 remain blocking."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
|
if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
|
||||||
"$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details"
|
if run_captured "health audit" "$SCRIPTS/mosaic-doctor"; then
|
||||||
|
ok "Health audit passed"
|
||||||
|
else
|
||||||
|
warn "Health audit found unresolved state; policy: preserve its diagnostics and let P9 issue the authoritative failure."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Write version stamp AFTER everything succeeds
|
# The version stamp records the successfully committed framework file sync.
|
||||||
|
# Post-install failures are carried separately into P4/P6 and cannot be erased
|
||||||
|
# by this stamp.
|
||||||
write_framework_version
|
write_framework_version
|
||||||
|
|
||||||
# ── Summary ──────────────────────────────────────────────────
|
# ── Summary ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -68,8 +68,15 @@ copy_claude_settings_guarded() {
|
|||||||
guard_args+=(--allow-inactive-enforcement)
|
guard_args+=(--allow-inactive-enforcement)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v mosaic >/dev/null 2>&1; then
|
local mosaic_cli="${MOSAIC_CLI_PATH:-}"
|
||||||
if mosaic "${guard_args[@]}"; then
|
# Unified install passes P3's committed absolute artifact. Standalone
|
||||||
|
# framework installs may resolve PATH once, but still invoke the resulting
|
||||||
|
# absolute path rather than a bare command.
|
||||||
|
if [[ -z "$mosaic_cli" ]]; then
|
||||||
|
mosaic_cli="$(command -v mosaic 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ "$mosaic_cli" == /* && -x "$mosaic_cli" ]]; then
|
||||||
|
if "$mosaic_cli" "${guard_args[@]}"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2
|
echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2
|
||||||
@@ -77,7 +84,7 @@ copy_claude_settings_guarded() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement" >&2
|
echo "[mosaic-link] ERROR: P3 absolute mosaic CLI unavailable — cannot confirm lease-enforcement" >&2
|
||||||
echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2
|
echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2
|
||||||
echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2
|
echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2
|
||||||
echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2
|
echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2
|
||||||
|
|||||||
@@ -499,17 +499,8 @@ get_gitea_url_for_host() {
|
|||||||
|
|
||||||
# Resolve a Gitea API token for the given host.
|
# Resolve a Gitea API token for the given host.
|
||||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||||
_trace_credential_resolution() {
|
|
||||||
[[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]] || return 0
|
|
||||||
local reason="$1" identity="$2" host="$3" source="$4"
|
|
||||||
local shared_path_entered=false
|
|
||||||
[[ "$_resolution_path" == shared ]] && shared_path_entered=true
|
|
||||||
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \
|
|
||||||
"$reason" "$identity" "$host" "$_resolution_path" "$shared_path_entered" "$source" >&2
|
|
||||||
}
|
|
||||||
|
|
||||||
get_gitea_token() {
|
get_gitea_token() {
|
||||||
local host="$1" _resolution_path=unresolved
|
local host="$1"
|
||||||
local script_dir
|
local script_dir
|
||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
local cred_loader="$script_dir/../_lib/credentials.sh"
|
local cred_loader="$script_dir/../_lib/credentials.sh"
|
||||||
@@ -525,16 +516,6 @@ get_gitea_token() {
|
|||||||
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
|
||||||
_ident_src="git config mosaic.gitIdentity"
|
_ident_src="git config mosaic.gitIdentity"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$_ident" && ! "$_ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
|
|
||||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity=<invalid> host=%s shared_path_entered=false source=%s\n' \
|
|
||||||
"$host" "$_ident_src" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ -n "${MOSAIC_AGENT_NAME:-}" && -n "$_ident" && "$_ident" != "$MOSAIC_AGENT_NAME" ]]; then
|
|
||||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=%s\n' \
|
|
||||||
"$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ -n "$_ident" ]]; then
|
if [[ -n "$_ident" ]]; then
|
||||||
local _idpfx=""
|
local _idpfx=""
|
||||||
case "$host" in
|
case "$host" in
|
||||||
@@ -543,23 +524,8 @@ get_gitea_token() {
|
|||||||
esac
|
esac
|
||||||
if [[ -n "$_idpfx" ]]; then
|
if [[ -n "$_idpfx" ]]; then
|
||||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||||
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
|
if [[ -r "$_idtok" ]]; then
|
||||||
if [[ -e "$_idcred" || -L "$_idcred" ]]; then
|
cat "$_idtok"
|
||||||
local _resolved_token
|
|
||||||
_resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
|
||||||
"$HOME/.config/mosaic/secrets/gitea-tokens" "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
|
|
||||||
_resolution_path=identity
|
|
||||||
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
|
|
||||||
printf '%s\n' "$_resolved_token"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [[ -e "$_idtok" || -L "$_idtok" ]]; then
|
|
||||||
local _resolved_token
|
|
||||||
_resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" \
|
|
||||||
"$HOME/.config/mosaic/secrets/gitea-tokens" "$_idtok") || return 1
|
|
||||||
_resolution_path=identity
|
|
||||||
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
|
|
||||||
printf '%s\n' "$_resolved_token"
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
|
||||||
@@ -568,21 +534,12 @@ get_gitea_token() {
|
|||||||
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
|
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
|
||||||
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
|
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
|
||||||
# caller aborts loudly rather than acting as the wrong identity.
|
# caller aborts loudly rather than acting as the wrong identity.
|
||||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=%s path=%s\n' \
|
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
|
||||||
"$_ident" "$host" "$_ident_src" "$_idtok" >&2
|
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fleet automation never borrows a shared human/default credential. An
|
|
||||||
# explicit interactive caller may still reach the shared paths below, but
|
|
||||||
# a fleet process must name an identity and resolve that identity exactly.
|
|
||||||
if [[ -n "${MOSAIC_AGENT_NAME:-}" ]]; then
|
|
||||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity=<unset> host=%s shared_path_entered=false source=MOSAIC_AGENT_NAME\n' \
|
|
||||||
"$host" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
|
||||||
if [[ -f "$cred_loader" ]]; then
|
if [[ -f "$cred_loader" ]]; then
|
||||||
local token
|
local token
|
||||||
@@ -614,8 +571,6 @@ get_gitea_token() {
|
|||||||
echo "${GITEA_TOKEN:-}"
|
echo "${GITEA_TOKEN:-}"
|
||||||
)
|
)
|
||||||
if [[ -n "$token" ]]; then
|
if [[ -n "$token" ]]; then
|
||||||
_resolution_path=shared
|
|
||||||
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
|
|
||||||
echo "$token"
|
echo "$token"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -624,8 +579,6 @@ get_gitea_token() {
|
|||||||
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||||
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
|
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
|
||||||
_resolution_path=shared
|
|
||||||
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" environment
|
|
||||||
echo "$GITEA_TOKEN"
|
echo "$GITEA_TOKEN"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -637,8 +590,6 @@ get_gitea_token() {
|
|||||||
local token
|
local token
|
||||||
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
|
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
|
||||||
if [[ -n "$token" ]]; then
|
if [[ -n "$token" ]]; then
|
||||||
_resolution_path=shared
|
|
||||||
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" git-credentials
|
|
||||||
echo "$token"
|
echo "$token"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -24,79 +24,29 @@ while IFS= read -r line; do
|
|||||||
username=*) username_in=${line#username=};;
|
username=*) username_in=${line#username=};;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
trace_resolution() {
|
|
||||||
[ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ] || return 0
|
|
||||||
reason="$1" trace_identity="$2" trace_host="$3" source="$4"
|
|
||||||
shared_path_entered=false
|
|
||||||
[ "$resolution_path" = shared ] && shared_path_entered=true
|
|
||||||
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \
|
|
||||||
"$reason" "$trace_identity" "$trace_host" "$resolution_path" "$shared_path_entered" "$source" >&2
|
|
||||||
}
|
|
||||||
resolution_path=unresolved
|
|
||||||
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
|
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
|
||||||
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
||||||
# survives across non-persistent shells) > git-supplied username (credential.username
|
# survives across non-persistent shells) > git-supplied username (credential.username
|
||||||
# / URL). When the resolved identity has a matching per-agent token, use it instead of
|
# / URL). When the resolved identity has a matching per-agent token, use it instead of
|
||||||
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
||||||
case "$host" in
|
|
||||||
git.uscllc.com) idpfx=gitea-usc;;
|
|
||||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
|
||||||
*) exit 0;;
|
|
||||||
esac
|
|
||||||
ident="$MOSAIC_GIT_IDENTITY"
|
ident="$MOSAIC_GIT_IDENTITY"
|
||||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||||
[ -z "$ident" ] && ident="$username_in"
|
[ -z "$ident" ] && ident="$username_in"
|
||||||
if [[ -n "$ident" && ! "$ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then
|
|
||||||
echo "quit=true"
|
|
||||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity=<invalid> host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -n "$idpfx" ] && [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
|
|
||||||
echo "quit=true"
|
|
||||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
|
|
||||||
"$ident" "$MOSAIC_AGENT_NAME" "$host" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ -n "$ident" ]; then
|
if [ -n "$ident" ]; then
|
||||||
|
case "$host" in
|
||||||
|
git.uscllc.com) idpfx=gitea-usc;;
|
||||||
|
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||||
|
*) idpfx="";;
|
||||||
|
esac
|
||||||
if [ -n "$idpfx" ]; then
|
if [ -n "$idpfx" ]; then
|
||||||
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
||||||
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
|
if [ -r "$idtok" ]; then
|
||||||
if [ -e "$idcred" ] || [ -L "$idcred" ]; then
|
|
||||||
token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
|
||||||
"$HOME/.config/mosaic/secrets/gitea-tokens" "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
|
|
||||||
resolution_path=identity
|
|
||||||
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
|
|
||||||
echo "username=${ident}"
|
echo "username=${ident}"
|
||||||
echo "password=${token}"
|
echo "password=$(cat "$idtok")"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ -e "$idtok" ] || [ -L "$idtok" ]; then
|
|
||||||
token=$(python3 "$script_dir/resolve-legacy-token.py" \
|
|
||||||
"$HOME/.config/mosaic/secrets/gitea-tokens" "$idtok") || exit 1
|
|
||||||
resolution_path=identity
|
|
||||||
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
|
|
||||||
echo "username=${ident}"
|
|
||||||
echo "password=${token}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
|
|
||||||
echo "quit=true"
|
|
||||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=git-credential-mosaic path=%s\n' \
|
|
||||||
"$ident" "$host" "$idtok" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -z "$ident" ]; then
|
|
||||||
case "$host" in
|
|
||||||
git.uscllc.com|git.mosaicstack.dev)
|
|
||||||
echo "quit=true"
|
|
||||||
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity=<unset> host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
case "$host" in
|
case "$host" in
|
||||||
git.uscllc.com) svc=gitea-usc;;
|
git.uscllc.com) svc=gitea-usc;;
|
||||||
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
||||||
@@ -105,11 +55,10 @@ esac
|
|||||||
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
||||||
# of where the framework installer places tools/ under $HOME — mirrors
|
# of where the framework installer places tools/ under $HOME — mirrors
|
||||||
# detect-platform.sh's own cred_loader resolution in this same directory.
|
# detect-platform.sh's own cred_loader resolution in this same directory.
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
# shellcheck source=../_lib/credentials.sh
|
# shellcheck source=../_lib/credentials.sh
|
||||||
source "$script_dir/../_lib/credentials.sh"
|
source "$script_dir/../_lib/credentials.sh"
|
||||||
load_credentials "$svc" >/dev/null 2>&1 || exit 0
|
load_credentials "$svc" >/dev/null 2>&1 || exit 0
|
||||||
resolution_path=shared
|
|
||||||
trace_resolution shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
|
|
||||||
# GITEA_USER is not populated by load_credentials (it only exports
|
# GITEA_USER is not populated by load_credentials (it only exports
|
||||||
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
|
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
|
||||||
# git-over-HTTP auth authenticates from the token itself (the password field),
|
# git-over-HTTP auth authenticates from the token itself (the password field),
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fail-closed reader for one governed Mosaic credential envelope."""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
import sys
|
|
||||||
|
|
||||||
MAX_BYTES = 64 * 1024
|
|
||||||
EXPECTED_KEYS = {
|
|
||||||
"schemaVersion",
|
|
||||||
"identity",
|
|
||||||
"estate",
|
|
||||||
"host",
|
|
||||||
"providerLogin",
|
|
||||||
"tokenName",
|
|
||||||
"scopes",
|
|
||||||
"createdAt",
|
|
||||||
"tokenDigest",
|
|
||||||
"token",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def refuse(message: str) -> None:
|
|
||||||
print(f"credential envelope refused: {message}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if len(sys.argv) != 6:
|
|
||||||
refuse("expected governed root, path, identity, estate, and host")
|
|
||||||
root, path, identity, estate, host = sys.argv[1:]
|
|
||||||
if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root):
|
|
||||||
refuse("credential is not a direct child of the governed root")
|
|
||||||
if not estate:
|
|
||||||
refuse("explicit estate is required")
|
|
||||||
parent = os.path.dirname(path)
|
|
||||||
try:
|
|
||||||
parent_stat = os.stat(parent, follow_symlinks=False)
|
|
||||||
except OSError:
|
|
||||||
refuse("credential directory unavailable")
|
|
||||||
if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode):
|
|
||||||
refuse("credential directory is not a real directory")
|
|
||||||
if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022:
|
|
||||||
refuse("credential directory owner or mode is unsafe")
|
|
||||||
try:
|
|
||||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
||||||
except OSError:
|
|
||||||
refuse("credential file unavailable or symbolic")
|
|
||||||
try:
|
|
||||||
file_stat = os.fstat(fd)
|
|
||||||
if not stat.S_ISREG(file_stat.st_mode):
|
|
||||||
refuse("credential is not a regular file")
|
|
||||||
if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077:
|
|
||||||
refuse("credential owner or mode is unsafe")
|
|
||||||
content = os.read(fd, MAX_BYTES + 1)
|
|
||||||
if len(content) > MAX_BYTES:
|
|
||||||
refuse("credential exceeds size limit")
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
try:
|
|
||||||
value = json.loads(content)
|
|
||||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
||||||
refuse("credential is not valid JSON")
|
|
||||||
if not isinstance(value, dict) or set(value) != EXPECTED_KEYS:
|
|
||||||
refuse("credential schema is not exact")
|
|
||||||
if (
|
|
||||||
value.get("schemaVersion") != 1
|
|
||||||
or value.get("identity") != identity
|
|
||||||
or value.get("estate") != estate
|
|
||||||
or value.get("host") != host
|
|
||||||
or value.get("providerLogin") != identity
|
|
||||||
):
|
|
||||||
refuse("credential binding does not match requested identity, estate, host, and principal")
|
|
||||||
token = value.get("token")
|
|
||||||
if not isinstance(token, str) or not token or any(ch.isspace() for ch in token):
|
|
||||||
refuse("credential token is invalid")
|
|
||||||
if value.get("tokenDigest") != hashlib.sha256(token.encode()).hexdigest():
|
|
||||||
refuse("credential digest does not match token")
|
|
||||||
sys.stdout.write(token + "\n")
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fail-closed reader for one legacy per-seat token file."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
import sys
|
|
||||||
|
|
||||||
MAX_BYTES = 16 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def refuse(message: str) -> None:
|
|
||||||
print(f"legacy credential refused: {message}", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
refuse("expected governed root and token path")
|
|
||||||
root, path = sys.argv[1:]
|
|
||||||
if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root):
|
|
||||||
refuse("credential is not a direct child of the governed root")
|
|
||||||
parent = os.path.dirname(path)
|
|
||||||
try:
|
|
||||||
parent_stat = os.stat(parent, follow_symlinks=False)
|
|
||||||
except OSError:
|
|
||||||
refuse("credential directory unavailable")
|
|
||||||
if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode):
|
|
||||||
refuse("credential directory is not a real directory")
|
|
||||||
if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022:
|
|
||||||
refuse("credential directory owner or mode is unsafe")
|
|
||||||
try:
|
|
||||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
||||||
except OSError:
|
|
||||||
refuse("credential file unavailable or symbolic")
|
|
||||||
try:
|
|
||||||
file_stat = os.fstat(fd)
|
|
||||||
if not stat.S_ISREG(file_stat.st_mode):
|
|
||||||
refuse("credential is not a regular file")
|
|
||||||
if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077:
|
|
||||||
refuse("credential owner or mode is unsafe")
|
|
||||||
content = os.read(fd, MAX_BYTES + 1)
|
|
||||||
if len(content) > MAX_BYTES:
|
|
||||||
refuse("credential exceeds size limit")
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
try:
|
|
||||||
token = content.decode("utf-8").strip()
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
refuse("credential is not UTF-8")
|
|
||||||
if not token or any(ch.isspace() for ch in token):
|
|
||||||
refuse("credential token is invalid")
|
|
||||||
sys.stdout.write(token + "\n")
|
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
umask 077
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
||||||
@@ -35,8 +34,6 @@ mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
|||||||
"$REPO_DIR"
|
"$REPO_DIR"
|
||||||
|
|
||||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||||
cp "$SCRIPT_DIR/resolve-credential-envelope.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-credential-envelope.py"
|
|
||||||
cp "$SCRIPT_DIR/resolve-legacy-token.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-legacy-token.py"
|
|
||||||
chmod +x "$HELPER"
|
chmod +x "$HELPER"
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
git -C "$REPO_DIR" init -q
|
||||||
@@ -87,22 +84,6 @@ git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
|||||||
out=$(run_helper "git.mosaicstack.dev" "")
|
out=$(run_helper "git.mosaicstack.dev" "")
|
||||||
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/shared-trace.stderr")
|
|
||||||
err=$(cat "$WORK_DIR/shared-trace.stderr")
|
|
||||||
if [[ "$err" != *"resolution_path=shared"* || "$err" != *"shared_path_entered=true"* ]]; then
|
|
||||||
echo "FAIL: shared credential materialization did not emit its computed path" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
set +e
|
|
||||||
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr")
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
err=$(cat "$WORK_DIR/fleet-unset.stderr")
|
|
||||||
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then
|
|
||||||
echo "FAIL: fleet unset identity did not stop at the resolver marker" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
|
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
|
||||||
@@ -112,21 +93,6 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
|
|||||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||||
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentA MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/identity-trace.stderr")
|
|
||||||
err=$(cat "$WORK_DIR/identity-trace.stderr")
|
|
||||||
if [[ "$err" != *"resolution_path=identity"* || "$err" != *"shared_path_entered=false"* ]]; then
|
|
||||||
echo "FAIL: identity credential did not emit its computed path" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
set +e
|
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
|
|
||||||
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
|
|
||||||
echo "FAIL: fleet identity override was not refused before token resolution" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
||||||
@@ -154,16 +120,6 @@ out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
|
|||||||
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
|
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
set +e
|
|
||||||
out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=no-such-agent 2>"$WORK_DIR/fleet-missing.stderr")
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
err=$(cat "$WORK_DIR/fleet-missing.stderr")
|
|
||||||
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=no-token-for-identity"* || "$err" != *"shared_path_entered=false"* ]]; then
|
|
||||||
echo "FAIL: fleet missing token did not stop at the resolver marker" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
|
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
|
||||||
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||||
@@ -179,47 +135,14 @@ assert_eq "host-scoped token path (cross-host must not leak): username" "usernam
|
|||||||
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 7. Governed envelopes use the same binding, owner, mode, and digest checks.
|
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
envelope="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.credential.json"
|
|
||||||
python3 - "$envelope" <<'PY'
|
|
||||||
import hashlib, json, sys
|
|
||||||
secret = "agentE-envelope-token"
|
|
||||||
json.dump({
|
|
||||||
"schemaVersion": 1, "identity": "agentE", "estate": "homelab",
|
|
||||||
"host": "git.mosaicstack.dev", "providerLogin": "agentE",
|
|
||||||
"tokenName": "mosaic-agentE-1", "scopes": ["write:repository"],
|
|
||||||
"createdAt": "2026-08-05T00:00:00.000Z",
|
|
||||||
"tokenDigest": hashlib.sha256(secret.encode()).hexdigest(), "token": secret,
|
|
||||||
}, open(sys.argv[1], "w", encoding="utf-8"))
|
|
||||||
PY
|
|
||||||
chmod 600 "$envelope"
|
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab)
|
|
||||||
assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')"
|
|
||||||
echo -n "must-not-fallback-legacy" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.token"
|
|
||||||
chmod 640 "$envelope"
|
|
||||||
set +e
|
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr")
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
if [[ "$rc" -eq 0 || "$out" == *"password="* ]]; then
|
|
||||||
echo "FAIL: permissive envelope was consumed" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 8. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
|
||||||
# remotes, e.g. github.com via a different credential helper).
|
# remotes, e.g. github.com via a different credential helper).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
out=$(run_helper "github.com" "agentA")
|
out=$(run_helper "github.com" "agentA")
|
||||||
assert_eq "unknown host: no output" "" "$out"
|
assert_eq "unknown host: no output" "" "$out"
|
||||||
out=$(run_helper "github.com" "[email protected]")
|
|
||||||
assert_eq "unknown host with non-Mosaic username: no output" "" "$out"
|
|
||||||
out=$(run_helper "github.com" "github-user" MOSAIC_AGENT_NAME=agentA)
|
|
||||||
assert_eq "unknown host in fleet context: no output" "" "$out"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
|
||||||
# protocol: this helper only implements get).
|
# protocol: this helper only implements get).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
umask 077
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
||||||
@@ -86,23 +85,7 @@ call_get_gitea_token() {
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||||
assert_eq "interactive shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
||||||
|
|
||||||
# Fleet context with no explicit identity refuses before the shared path. This
|
|
||||||
# is the marker-emission positive control for the fail-closed mechanism.
|
|
||||||
set +e
|
|
||||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/stderr-fleet-unset.tmp")
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
err=$(cat "$WORK_DIR/stderr-fleet-unset.tmp")
|
|
||||||
if [[ "$rc" -eq 0 || -n "$out" ]]; then
|
|
||||||
echo "FAIL: fleet unset identity must refuse with empty stdout" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
if [[ "$err" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then
|
|
||||||
echo "FAIL: fleet unset identity did not emit the stable resolver refusal marker: $err" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
|
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
|
||||||
@@ -110,17 +93,8 @@ fi
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
|
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
|
||||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA)
|
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||||
assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out"
|
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
|
||||||
set +e
|
|
||||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA MOSAIC_GIT_IDENTITY=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
|
|
||||||
if [[ "$rc" -eq 0 || -n "$out" || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
|
|
||||||
echo "FAIL: fleet identity override was not refused before token resolution" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||||
@@ -169,17 +143,13 @@ assert_failloud() {
|
|||||||
echo "$stderr" >&2
|
echo "$stderr" >&2
|
||||||
fail=1
|
fail=1
|
||||||
fi
|
fi
|
||||||
if [[ "$stderr" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$stderr" != *"reason=no-token-for-identity"* || "$stderr" != *"shared_path_entered=false"* ]]; then
|
|
||||||
echo "FAIL: $desc — stable resolver refusal marker missing: $stderr" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
if [[ "$stderr" != *"$expected_tok_path"* ]]; then
|
if [[ "$stderr" != *"$expected_tok_path"* ]]; then
|
||||||
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
|
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
|
||||||
echo "$stderr" >&2
|
echo "$stderr" >&2
|
||||||
fail=1
|
fail=1
|
||||||
fi
|
fi
|
||||||
if [[ "$stderr" == *"shared-mosaicstack-token"* || "$stderr" == *"shared-usc-token"* ]]; then
|
if [[ "$stderr" == *"shared"*"token"* ]]; then
|
||||||
echo "FAIL: $desc — stderr unexpectedly contains a shared credential value:" >&2
|
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
|
||||||
echo "$stderr" >&2
|
echo "$stderr" >&2
|
||||||
fail=1
|
fail=1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -180,15 +180,16 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
|
|||||||
|
|
||||||
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
|
||||||
# A bash signal trap that merely returns lets the script continue past the
|
# A bash signal trap that merely returns lets the script continue past the
|
||||||
# interrupt — restoring the snapshot, then resuming the sync and reporting
|
# interrupt — restoring the snapshot, then resuming the install and reporting
|
||||||
# success. The earlier test used a child cp shim to signal its parent, making
|
# success. Generate two installer fixtures that first damage a real target path
|
||||||
# child completion race Bash's interrupted wait. Concurrency is not part of the
|
# after the snapshot is armed, then signal their own Bash process immediately
|
||||||
# guarded property: sync_framework_keep() runs in the installer's own Bash
|
# before the normal sync. This fixed injection point is independent of `find`
|
||||||
# process, and `kill` is a builtin. Generate two installer fixtures that signal
|
# enumeration order: after a no-exit handler restores and returns, the complete
|
||||||
# themselves at the same known mid-sync point. Their TERM handlers emit the same
|
# sync still remains to run, so the historical resume bug is deterministic on
|
||||||
# observable before diverging, so missing signal delivery fails BOTH arms rather
|
# every filesystem. Their TERM handlers emit the same observable before
|
||||||
# than manufacturing a pass. The only semantic difference between fixtures is
|
# diverging, so missing signal delivery fails BOTH arms rather than manufacturing
|
||||||
# the explicit `exit 1` whose load-bearing behavior this control proves.
|
# a pass. The only semantic difference between fixtures is the explicit `exit 1`
|
||||||
|
# whose load-bearing behavior this control proves.
|
||||||
TERM_MARKER='[test-control] TERM handler entered'
|
TERM_MARKER='[test-control] TERM handler entered'
|
||||||
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
|
||||||
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
|
||||||
@@ -196,19 +197,17 @@ HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM #
|
|||||||
make_signal_installer() {
|
make_signal_installer() {
|
||||||
local output="$1" handler="$2"
|
local output="$1" handler="$2"
|
||||||
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
|
||||||
local target_cp=' cp "$abs" "$dst/$rel"'
|
local target_sync='sync_framework'
|
||||||
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
|
local inject_damage="printf '%s' '$GARBAGE' > \"\$TARGET_DIR/$POISON_REL\" # TEST-TERM-DAMAGE"
|
||||||
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
|
local inject_kill='kill -TERM "$$" # TEST-TERM-INJECTION'
|
||||||
local inject_close=' fi'
|
|
||||||
|
|
||||||
if ! awk \
|
if ! awk \
|
||||||
-v target_trap="$target_trap" -v target_cp="$target_cp" \
|
-v target_trap="$target_trap" -v target_sync="$target_sync" \
|
||||||
-v handler="$handler" -v inject_open="$inject_open" \
|
-v handler="$handler" -v inject_damage="$inject_damage" \
|
||||||
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
|
-v inject_kill="$inject_kill" '
|
||||||
$0 == target_cp {
|
$0 == target_sync {
|
||||||
print inject_open
|
print inject_damage
|
||||||
print inject_kill
|
print inject_kill
|
||||||
print inject_close
|
|
||||||
injection_sites++
|
injection_sites++
|
||||||
}
|
}
|
||||||
{ print }
|
{ print }
|
||||||
@@ -231,7 +230,8 @@ make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
|
|||||||
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
|
||||||
signal_fixture_ready() {
|
signal_fixture_ready() {
|
||||||
local fixture="$1" expected_handler="$2"
|
local fixture="$1" expected_handler="$2"
|
||||||
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
[[ "$(grep -cF '# TEST-TERM-DAMAGE' "$fixture")" -eq 1 ]] \
|
||||||
|
&& [[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
|
||||||
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
|
||||||
&& grep -Fqx "$expected_handler" "$fixture"
|
&& grep -Fqx "$expected_handler" "$fixture"
|
||||||
}
|
}
|
||||||
@@ -261,10 +261,12 @@ run_signal_upgrade() {
|
|||||||
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
|
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
|
||||||
chk "[signal] TERM handler observable fires exactly once" \
|
chk "[signal] TERM handler observable fires exactly once" \
|
||||||
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
|
||||||
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
|
chk "[signal] SIGTERM after target mutation aborts non-zero (trap exits, does not resume)" \
|
||||||
"[ '$rcC' -ne 0 ]"
|
"[ '$rcC' -ne 0 ]"
|
||||||
chk "[signal] restore_snapshot fires on the interrupt" \
|
chk "[signal] restore_snapshot fires on the interrupt" \
|
||||||
"grep -q 'restoring previous state from snapshot' '$OUTC'"
|
"grep -q 'restoring previous state from snapshot' '$OUTC'"
|
||||||
|
chk "[signal] the deliberately damaged target is restored before termination" \
|
||||||
|
"[ \"\$(cat '$HC/$POISON_REL')\" = '$GOOD' ]"
|
||||||
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
chk "[signal] does NOT resume to report sync success after the interrupt" \
|
||||||
"! grep -q 'file phase complete' '$OUTC'"
|
"! grep -q 'file phase complete' '$OUTC'"
|
||||||
|
|
||||||
@@ -277,6 +279,8 @@ chk "[control] without 'exit 1' the installer exits zero after resuming" \
|
|||||||
"[ '$rcD' -eq 0 ]"
|
"[ '$rcD' -eq 0 ]"
|
||||||
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
|
||||||
"grep -q 'file phase complete' '$OUTD'"
|
"grep -q 'file phase complete' '$OUTD'"
|
||||||
|
chk "[control] the resumed full sync mutates the restored target again" \
|
||||||
|
"! grep -qxF '$GOOD' '$HD/$POISON_REL' && cmp -s '$FW/$POISON_REL' '$HD/$POISON_REL'"
|
||||||
|
|
||||||
# ── Part D: a failed source/prune `find` scan must abort + roll back (D1) ─────
|
# ── Part D: a failed source/prune `find` scan must abort + roll back (D1) ─────
|
||||||
# A `< <(find …)` process substitution discards find's exit status, so an
|
# A `< <(find …)` process substitution discards find's exit status, so an
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { registerTelemetryCommand } from './commands/telemetry.js';
|
|||||||
import { registerAgentCommand } from './commands/agent.js';
|
import { registerAgentCommand } from './commands/agent.js';
|
||||||
import { registerInteractionCommand } from './commands/interaction.js';
|
import { registerInteractionCommand } from './commands/interaction.js';
|
||||||
import { registerConfigCommand } from './commands/config.js';
|
import { registerConfigCommand } from './commands/config.js';
|
||||||
import { registerCredentialCommand } from './commands/cred.js';
|
|
||||||
import { registerFleetCommand } from './commands/fleet.js';
|
import { registerFleetCommand } from './commands/fleet.js';
|
||||||
import { registerMissionCommand } from './commands/mission.js';
|
import { registerMissionCommand } from './commands/mission.js';
|
||||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||||
@@ -372,10 +371,6 @@ registerInteractionCommand(program);
|
|||||||
|
|
||||||
registerFleetCommand(program);
|
registerFleetCommand(program);
|
||||||
|
|
||||||
// ─── credential governance ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
registerCredentialCommand(program);
|
|
||||||
|
|
||||||
// ─── config ────────────────────────────────────────────────────────────
|
// ─── config ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
registerConfigCommand(program);
|
registerConfigCommand(program);
|
||||||
|
|||||||
@@ -1,795 +0,0 @@
|
|||||||
import {
|
|
||||||
chmod,
|
|
||||||
mkdtemp,
|
|
||||||
mkdir,
|
|
||||||
open,
|
|
||||||
readFile,
|
|
||||||
readdir,
|
|
||||||
rename,
|
|
||||||
rm,
|
|
||||||
symlink,
|
|
||||||
unlink,
|
|
||||||
writeFile,
|
|
||||||
} from 'node:fs/promises';
|
|
||||||
import { writeSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
CredentialAuditJournal,
|
|
||||||
CredentialJournalError,
|
|
||||||
listCredentialJournals,
|
|
||||||
} from '../credentials/audit-journal.js';
|
|
||||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
|
||||||
import { FileCredentialStore } from '../credentials/file-credential-store.js';
|
|
||||||
import { credentialLifecycleLocksDirectory } from '../credentials/lifecycle.js';
|
|
||||||
import { TeaLoginStore } from '../credentials/tea-login-store.js';
|
|
||||||
import { executeCredentialGet, executeCredentialRotate, executeCredentialWire } from './cred.js';
|
|
||||||
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fixture(): Promise<{
|
|
||||||
readonly mosaicHome: string;
|
|
||||||
readonly registryPath: string;
|
|
||||||
readonly tokenDirectory: string;
|
|
||||||
readonly stateRoot: string;
|
|
||||||
}> {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-command-'));
|
|
||||||
await chmod(cleanup, 0o700);
|
|
||||||
const mosaicHome = join(cleanup, 'mosaic');
|
|
||||||
const credentialDirectory = join(mosaicHome, 'cred');
|
|
||||||
await mkdir(credentialDirectory, { recursive: true, mode: 0o700 });
|
|
||||||
const registryPath = join(credentialDirectory, 'estates.json');
|
|
||||||
await writeFile(
|
|
||||||
registryPath,
|
|
||||||
JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
estates: [
|
|
||||||
{
|
|
||||||
name: 'homelab',
|
|
||||||
hosts: [
|
|
||||||
{
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
provider: 'gitea',
|
|
||||||
apiBaseUrl: 'https://git.example.invalid',
|
|
||||||
tokenPrefix: 'gitea-example',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
mosaicHome,
|
|
||||||
registryPath,
|
|
||||||
tokenDirectory: join(mosaicHome, 'secrets', 'gitea-tokens'),
|
|
||||||
stateRoot: join(cleanup, 'state'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('credential lifecycle command controls', (): void => {
|
|
||||||
it('returns the visible open rotation journal when protected authority resolution fails', async (): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
|
||||||
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
|
|
||||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
|
||||||
await store.put(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
providerLogin: 'seat-name',
|
|
||||||
tokenName: 'old-generation',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
createdAt: '2026-08-05T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
new TextEncoder().encode('old-token-canary'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await executeCredentialRotate('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: '999',
|
|
||||||
tokenName: 'new-generation',
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('error');
|
|
||||||
expect(result.mutation).toBe('none');
|
|
||||||
expect(result.audit.state).toBe('open');
|
|
||||||
expect(result.audit.journalId).not.toBeNull();
|
|
||||||
await expect(listCredentialJournals(paths.stateRoot)).resolves.toContainEqual(
|
|
||||||
expect.objectContaining({ id: result.audit.journalId, state: 'open' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['partialWrite', 'shortWrite', 'recordMutation', 'seal'] as const)(
|
|
||||||
'handles %s without overstating credential disclosure',
|
|
||||||
async (method): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
|
||||||
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
|
|
||||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
|
||||||
await store.put(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
providerLogin: 'seat-name',
|
|
||||||
tokenName: 'active-generation',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
createdAt: '2026-08-05T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
new TextEncoder().encode('seat-token-canary'),
|
|
||||||
);
|
|
||||||
const authorityPath = join(cleanup!, 'authority.json');
|
|
||||||
const outputPath = join(cleanup!, 'credential.out');
|
|
||||||
await writeFile(
|
|
||||||
authorityPath,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'seat-token-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
async (): Promise<Response> =>
|
|
||||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (method === 'recordMutation') {
|
|
||||||
const original = CredentialAuditJournal.prototype.recordMutation;
|
|
||||||
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
|
|
||||||
async function (this: CredentialAuditJournal, decision): Promise<void> {
|
|
||||||
if (decision === 'credential-issued') {
|
|
||||||
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
|
|
||||||
}
|
|
||||||
await original.call(this, decision);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else if (method === 'seal') {
|
|
||||||
const original = CredentialAuditJournal.prototype.seal;
|
|
||||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
|
||||||
this: CredentialAuditJournal,
|
|
||||||
outcome,
|
|
||||||
reason,
|
|
||||||
): Promise<string> {
|
|
||||||
if (outcome === 'ok') {
|
|
||||||
throw new CredentialJournalError('journal-unavailable', 'injected seal failure');
|
|
||||||
}
|
|
||||||
return original.call(this, outcome, reason);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let writes = 0;
|
|
||||||
const authority = await open(authorityPath, 'r');
|
|
||||||
const output = await open(outputPath, 'w+', 0o600);
|
|
||||||
try {
|
|
||||||
const result = await executeCredentialGet('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: authority.fd.toString(),
|
|
||||||
outputFd: output.fd.toString(),
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
credentialWrite:
|
|
||||||
method === 'partialWrite'
|
|
||||||
? (fd, data): number => {
|
|
||||||
writes += 1;
|
|
||||||
if (writes === 2) throw new Error('injected partial write failure');
|
|
||||||
return writeSync(fd, data);
|
|
||||||
}
|
|
||||||
: method === 'shortWrite'
|
|
||||||
? (fd, data): number =>
|
|
||||||
writeSync(fd, data.subarray(0, Math.max(1, Math.floor(data.byteLength / 2))))
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
if (method === 'shortWrite') {
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
outcome: 'ok',
|
|
||||||
mutation: 'none',
|
|
||||||
reason: { code: 'get-verified' },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
outcome: 'indeterminate',
|
|
||||||
mutation: method === 'partialWrite' ? 'unknown' : 'applied',
|
|
||||||
reason: { code: 'credential-issuance-indeterminate' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const emitted = await readFile(outputPath, 'utf8');
|
|
||||||
if (method === 'partialWrite') {
|
|
||||||
expect(emitted).toContain('username=seat-name');
|
|
||||||
expect(emitted).not.toContain('seat-token-canary');
|
|
||||||
} else {
|
|
||||||
expect(emitted).toContain('password=seat-token-canary');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await output.close();
|
|
||||||
await authority.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['absent', 'divergent'] as const)(
|
|
||||||
'restores an independently %s Tea pre-state after rotation journal failure',
|
|
||||||
async (teaState): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
|
||||||
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
|
|
||||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
|
||||||
await store.put(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
providerLogin: 'seat-name',
|
|
||||||
tokenName: 'old-generation',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
createdAt: '2026-08-05T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
new TextEncoder().encode('old-token'),
|
|
||||||
);
|
|
||||||
const teaConfig = join(cleanup!, 'tea', 'config.yml');
|
|
||||||
const teaStore = new TeaLoginStore(teaConfig);
|
|
||||||
if (teaState === 'divergent') {
|
|
||||||
await teaStore.put(
|
|
||||||
'seat-name',
|
|
||||||
'git.example.invalid',
|
|
||||||
new TextEncoder().encode('divergent-tea-token'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const authorityPath = join(cleanup!, 'authority.json');
|
|
||||||
await writeFile(
|
|
||||||
authorityPath,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
let replacementRevoked = false;
|
|
||||||
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
|
|
||||||
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
|
||||||
const method = init?.method ?? 'GET';
|
|
||||||
if (url.pathname === '/api/v1/user') {
|
|
||||||
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (url.pathname.endsWith('/tokens') && method === 'POST') {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
name: 'new-generation',
|
|
||||||
sha1: 'replacement-token',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
}),
|
|
||||||
{ status: 201, headers: { 'content-type': 'application/json' } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (url.pathname.endsWith('/tokens') && method === 'GET') {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify(
|
|
||||||
replacementRevoked ? [] : [{ name: 'new-generation', scopes: ['write:repository'] }],
|
|
||||||
),
|
|
||||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (url.pathname.endsWith('/tokens/new-generation') && method === 'DELETE') {
|
|
||||||
replacementRevoked = true;
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected provider request: ${method} ${url.pathname}`);
|
|
||||||
});
|
|
||||||
let mintRecords = 0;
|
|
||||||
const recordMutation = CredentialAuditJournal.prototype.recordMutation;
|
|
||||||
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
|
|
||||||
async function (this: CredentialAuditJournal, decision): Promise<void> {
|
|
||||||
if (decision === 'token-mint-applied') {
|
|
||||||
mintRecords += 1;
|
|
||||||
if (mintRecords === 2) {
|
|
||||||
throw new CredentialJournalError('journal-unavailable', 'injected rotation failure');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await recordMutation.call(this, decision);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const authority = await open(authorityPath, 'r');
|
|
||||||
try {
|
|
||||||
const result = await executeCredentialRotate('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: authority.fd.toString(),
|
|
||||||
tokenName: 'new-generation',
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
teaConfig,
|
|
||||||
});
|
|
||||||
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
|
|
||||||
if (teaState === 'absent') {
|
|
||||||
expect(teaStore.snapshot('seat-name', 'git.example.invalid')).toBeUndefined();
|
|
||||||
} else {
|
|
||||||
expect(
|
|
||||||
teaStore.matchesSecret(
|
|
||||||
'seat-name',
|
|
||||||
'git.example.invalid',
|
|
||||||
new TextEncoder().encode('divergent-tea-token'),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await authority.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('preserves an open nested provision journal in the rotate result', async (): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
|
||||||
await mkdir(paths.tokenDirectory, { recursive: true, mode: 0o700 });
|
|
||||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
|
||||||
await store.put(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
providerLogin: 'seat-name',
|
|
||||||
tokenName: 'old-generation',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
createdAt: '2026-08-05T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
new TextEncoder().encode('old-token'),
|
|
||||||
);
|
|
||||||
const authorityPath = join(cleanup!, 'authority.json');
|
|
||||||
await writeFile(
|
|
||||||
authorityPath,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
const snapshot = TeaLoginStore.prototype.snapshot;
|
|
||||||
let snapshotCalls = 0;
|
|
||||||
vi.spyOn(TeaLoginStore.prototype, 'snapshot').mockImplementation(function (
|
|
||||||
this: TeaLoginStore,
|
|
||||||
identity,
|
|
||||||
host,
|
|
||||||
) {
|
|
||||||
snapshotCalls += 1;
|
|
||||||
if (snapshotCalls === 2) throw new Error('injected nested snapshot failure');
|
|
||||||
return snapshot.call(this, identity, host);
|
|
||||||
});
|
|
||||||
const seal = CredentialAuditJournal.prototype.seal;
|
|
||||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
|
||||||
this: CredentialAuditJournal,
|
|
||||||
outcome,
|
|
||||||
reasonCode,
|
|
||||||
): Promise<string> {
|
|
||||||
if (reasonCode === 'credential-snapshot-unavailable') {
|
|
||||||
throw new CredentialJournalError('journal-unavailable', 'injected nested seal failure');
|
|
||||||
}
|
|
||||||
return seal.call(this, outcome, reasonCode);
|
|
||||||
});
|
|
||||||
const authority = await open(authorityPath, 'r');
|
|
||||||
try {
|
|
||||||
const result = await executeCredentialRotate('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: authority.fd.toString(),
|
|
||||||
tokenName: 'new-generation',
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
teaConfig: join(cleanup!, 'tea', 'config.yml'),
|
|
||||||
});
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
operation: 'rotate',
|
|
||||||
outcome: 'error',
|
|
||||||
mutation: 'none',
|
|
||||||
audit: { state: 'open' },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await authority.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves the journal failure diagnosis when rotate lock failure cannot be sealed', async (): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
|
|
||||||
await mkdir(paths.tokenDirectory, { recursive: true, mode: 0o700 });
|
|
||||||
const store = new FileCredentialStore(paths.tokenDirectory, registry);
|
|
||||||
await store.put(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
providerLogin: 'seat-name',
|
|
||||||
tokenName: 'old-generation',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
createdAt: '2026-08-05T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
new TextEncoder().encode('old-token'),
|
|
||||||
);
|
|
||||||
const authorityPath = join(cleanup!, 'authority.json');
|
|
||||||
await writeFile(
|
|
||||||
authorityPath,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
|
||||||
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
|
|
||||||
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--seat-name.lock');
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
await symlink('/dev/null', lockPath);
|
|
||||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
|
|
||||||
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
|
|
||||||
);
|
|
||||||
const authority = await open(authorityPath, 'r');
|
|
||||||
try {
|
|
||||||
const result = await executeCredentialRotate('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: authority.fd.toString(),
|
|
||||||
tokenName: 'new-generation',
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
teaConfig: join(cleanup!, 'tea', 'config.yml'),
|
|
||||||
});
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
operation: 'rotate',
|
|
||||||
outcome: 'error',
|
|
||||||
mutation: 'none',
|
|
||||||
reason: { code: 'journal-recovery-required' },
|
|
||||||
audit: { state: 'open' },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await authority.close();
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses an unauthenticated actor before rewriting another seat environment', async (): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
||||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
||||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
||||||
const before = 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n';
|
|
||||||
await writeFile(seatEnvironment, before, { mode: 0o600 });
|
|
||||||
|
|
||||||
const result = await executeCredentialWire('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'intruder-seat',
|
|
||||||
seatEnv: seatEnvironment,
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('refused');
|
|
||||||
expect(result.mutation).toBe('none');
|
|
||||||
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('authenticates the exact seat and rewrites its roster-derived projection idempotently', async (): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
||||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
||||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
||||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\nMOSAIC_AGENT_CLASS=coder\n', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
const authorityPath = join(cleanup!, 'authority.json');
|
|
||||||
await writeFile(
|
|
||||||
authorityPath,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
async (): Promise<Response> =>
|
|
||||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const invoke = async () => {
|
|
||||||
const authority = await open(authorityPath, 'r');
|
|
||||||
try {
|
|
||||||
return await executeCredentialWire('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: authority.fd.toString(),
|
|
||||||
seatEnv: seatEnvironment,
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await authority.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const first = await invoke();
|
|
||||||
const afterFirst = await readFile(seatEnvironment, 'utf8');
|
|
||||||
const second = await invoke();
|
|
||||||
const afterSecond = await readFile(seatEnvironment, 'utf8');
|
|
||||||
|
|
||||||
expect(first.outcome).toBe('ok');
|
|
||||||
expect(second.outcome).toBe('ok');
|
|
||||||
expect(afterSecond).toBe(afterFirst);
|
|
||||||
expect(afterSecond).toContain('MOSAIC_GIT_IDENTITY=seat-name\n');
|
|
||||||
expect(afterSecond).toContain('MOSAIC_CREDENTIAL_ESTATE=homelab\n');
|
|
||||||
expect(afterSecond).toContain('GITEA_LOGIN=seat-name--git.example.invalid\n');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['recordMutation', 'seal'] as const)(
|
|
||||||
'reports an applied wire as indeterminate when audit %s fails after rename',
|
|
||||||
async (method): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
||||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
||||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
||||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
|
||||||
const authorityPath = join(cleanup!, 'authority.json');
|
|
||||||
await writeFile(
|
|
||||||
authorityPath,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
async (): Promise<Response> =>
|
|
||||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.spyOn(CredentialAuditJournal.prototype, method).mockRejectedValueOnce(
|
|
||||||
new CredentialJournalError('journal-unavailable', 'injected audit failure'),
|
|
||||||
);
|
|
||||||
const authority = await open(authorityPath, 'r');
|
|
||||||
try {
|
|
||||||
const result = await executeCredentialWire('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: authority.fd.toString(),
|
|
||||||
seatEnv: seatEnvironment,
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
});
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.mutation).toBe('applied');
|
|
||||||
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
|
|
||||||
} finally {
|
|
||||||
await authority.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('refuses to overwrite a roster projection replaced after validation', async (): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
||||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
||||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
||||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
|
||||||
const authorityPath = join(cleanup!, 'authority.json');
|
|
||||||
await writeFile(
|
|
||||||
authorityPath,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
async (): Promise<Response> =>
|
|
||||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const authority = await open(authorityPath, 'r');
|
|
||||||
try {
|
|
||||||
const result = await executeCredentialWire('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: authority.fd.toString(),
|
|
||||||
seatEnv: seatEnvironment,
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
wireBeforeRename: async (): Promise<void> => {
|
|
||||||
const replacement = join(agents, 'replacement');
|
|
||||||
await writeFile(replacement, 'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n', { mode: 0o600 });
|
|
||||||
await rename(replacement, seatEnvironment);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result.outcome).toBe('error');
|
|
||||||
expect(result.mutation).toBe('none');
|
|
||||||
expect(await readFile(seatEnvironment, 'utf8')).toBe(
|
|
||||||
'MOSAIC_AGENT_NAME=seat-name\nNEW=value\n',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await authority.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports directory-sync failure after rename as applied and indeterminate', async (): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
||||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
||||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
||||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
|
||||||
const authorityPath = join(cleanup!, 'authority.json');
|
|
||||||
await writeFile(
|
|
||||||
authorityPath,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
async (): Promise<Response> =>
|
|
||||||
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const authority = await open(authorityPath, 'r');
|
|
||||||
try {
|
|
||||||
const result = await executeCredentialWire('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: authority.fd.toString(),
|
|
||||||
seatEnv: seatEnvironment,
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
wireDirectorySync: async (): Promise<void> => {
|
|
||||||
throw new Error('injected directory sync failure');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.mutation).toBe('applied');
|
|
||||||
expect(await readFile(seatEnvironment, 'utf8')).toContain('MOSAIC_GIT_IDENTITY=seat-name');
|
|
||||||
} finally {
|
|
||||||
await authority.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes a temporary projection when directory revalidation fails before rename', async (): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
||||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
||||||
const seatEnvironment = join(agents, 'seat-name.env.generated');
|
|
||||||
await writeFile(seatEnvironment, 'MOSAIC_AGENT_NAME=seat-name\n', { mode: 0o600 });
|
|
||||||
const authorityPath = join(cleanup!, 'authority.json');
|
|
||||||
await writeFile(
|
|
||||||
authorityPath,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', async (): Promise<Response> => {
|
|
||||||
await chmod(agents, 0o777);
|
|
||||||
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const authority = await open(authorityPath, 'r');
|
|
||||||
try {
|
|
||||||
const result = await executeCredentialWire('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: authority.fd.toString(),
|
|
||||||
seatEnv: seatEnvironment,
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
});
|
|
||||||
expect(result.outcome).toBe('error');
|
|
||||||
expect(await readdir(agents)).toEqual(['seat-name.env.generated']);
|
|
||||||
} finally {
|
|
||||||
await authority.close();
|
|
||||||
await chmod(agents, 0o700);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a caller-selected seat filename that is not bound to the requested identity', async (): Promise<void> => {
|
|
||||||
const paths = await fixture();
|
|
||||||
const agents = join(paths.mosaicHome, 'fleet', 'agents');
|
|
||||||
await mkdir(agents, { recursive: true, mode: 0o700 });
|
|
||||||
const seatEnvironment = join(agents, 'other-seat.env.generated');
|
|
||||||
const before = 'MOSAIC_AGENT_NAME=other-seat\nMOSAIC_AGENT_CLASS=coder\n';
|
|
||||||
await writeFile(seatEnvironment, before, { mode: 0o600 });
|
|
||||||
|
|
||||||
const result = await executeCredentialWire('seat-name', {
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
actor: 'seat-name',
|
|
||||||
authorityFd: '999',
|
|
||||||
seatEnv: seatEnvironment,
|
|
||||||
mosaicHome: paths.mosaicHome,
|
|
||||||
registry: paths.registryPath,
|
|
||||||
tokenDir: paths.tokenDirectory,
|
|
||||||
stateDir: paths.stateRoot,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('refused');
|
|
||||||
expect(result.reason.code).toBe('credential-binding-mismatch');
|
|
||||||
await expect(readFile(seatEnvironment, 'utf8')).resolves.toBe(before);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
|||||||
import type { FileHandle } from 'node:fs/promises';
|
|
||||||
|
|
||||||
export type CredentialJournalOperation =
|
|
||||||
| 'provision'
|
|
||||||
| 'wire'
|
|
||||||
| 'grant'
|
|
||||||
| 'get'
|
|
||||||
| 'validate'
|
|
||||||
| 'rotate'
|
|
||||||
| 'revoke'
|
|
||||||
| 'whoami'
|
|
||||||
| 'list'
|
|
||||||
| 'audit';
|
|
||||||
|
|
||||||
export interface CredentialJournalContextDto {
|
|
||||||
readonly operation: CredentialJournalOperation;
|
|
||||||
readonly actor: string;
|
|
||||||
readonly identity: string;
|
|
||||||
readonly estate: string;
|
|
||||||
readonly host: string;
|
|
||||||
readonly repo: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialProviderJournalEvidenceDto {
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
readonly decision: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialJournalCorrectionDto {
|
|
||||||
readonly supersedesJournalId: string;
|
|
||||||
readonly correctedByJournalId: string;
|
|
||||||
readonly previousReason: string;
|
|
||||||
readonly correctedReason: string;
|
|
||||||
readonly previousOutcome?: 'ok' | 'refused' | 'error' | 'indeterminate';
|
|
||||||
readonly correctedOutcome?: 'ok' | 'refused' | 'error' | 'indeterminate';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialPopulationCorrectionDto {
|
|
||||||
readonly entries: readonly {
|
|
||||||
readonly identity: string;
|
|
||||||
readonly supersedesJournalIds: readonly string[];
|
|
||||||
readonly settledByJournalId: string;
|
|
||||||
readonly capability: 'confirmed';
|
|
||||||
readonly identityBinding: 'not-measured';
|
|
||||||
readonly mechanism: 'identity-scope-forbidden-in-scope-capability-confirmed';
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialJournalRuntimeOptionsDto {
|
|
||||||
readonly id?: string;
|
|
||||||
readonly now?: () => string;
|
|
||||||
readonly syncDirectory?: (path: string) => Promise<void>;
|
|
||||||
readonly rename?: (source: string, destination: string) => Promise<void>;
|
|
||||||
readonly write?: (
|
|
||||||
handle: FileHandle,
|
|
||||||
data: Uint8Array,
|
|
||||||
offset: number,
|
|
||||||
length: number,
|
|
||||||
) => Promise<number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialJournalSummaryDto {
|
|
||||||
readonly id: string;
|
|
||||||
readonly state: 'open' | 'sealed';
|
|
||||||
readonly path: string;
|
|
||||||
}
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
import {
|
|
||||||
chmod,
|
|
||||||
mkdir,
|
|
||||||
mkdtemp,
|
|
||||||
open,
|
|
||||||
readFile,
|
|
||||||
rename,
|
|
||||||
rm,
|
|
||||||
symlink,
|
|
||||||
truncate,
|
|
||||||
writeFile,
|
|
||||||
} from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import { CredentialAuditJournal, listCredentialJournals } from './audit-journal.js';
|
|
||||||
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
|
|
||||||
async function stateRoot(): Promise<string> {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-journal-'));
|
|
||||||
return join(cleanup, 'state');
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('credential durable audit journal', (): void => {
|
|
||||||
it('opens before mutation, appends provider evidence, and seals durably', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
const journal = await CredentialAuditJournal.open(
|
|
||||||
root,
|
|
||||||
{
|
|
||||||
operation: 'grant',
|
|
||||||
actor: 'provisioner',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
},
|
|
||||||
{ id: 'journal-id', now: (): string => '2026-08-05T00:00:00.000Z' },
|
|
||||||
);
|
|
||||||
|
|
||||||
await journal.recordIntent('provider-grant');
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: 'GET /api/v1/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
decision: 'permission-write',
|
|
||||||
});
|
|
||||||
const sealedPath = await journal.seal('ok', 'grant-verified');
|
|
||||||
|
|
||||||
expect(sealedPath).toMatch(/journal-id\.sealed\.jsonl$/);
|
|
||||||
const records = (await readFile(sealedPath, 'utf8')).trim().split('\n');
|
|
||||||
expect(records).toHaveLength(4);
|
|
||||||
expect(records[0]).toContain('"phase":"opened"');
|
|
||||||
expect(records[1]).toContain('"phase":"intent"');
|
|
||||||
expect(records[2]).toContain('"phase":"provider-evidence"');
|
|
||||||
expect(records[3]).toContain('"phase":"sealed"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes every journal record completely when each write makes one-byte progress', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
let writeCalls = 0;
|
|
||||||
const journal = await CredentialAuditJournal.open(
|
|
||||||
root,
|
|
||||||
{
|
|
||||||
operation: 'grant',
|
|
||||||
actor: 'provisioner',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'short-write',
|
|
||||||
now: (): string => '2026-08-05T00:00:00.000Z',
|
|
||||||
write: async (handle, data, offset, length): Promise<number> => {
|
|
||||||
writeCalls += 1;
|
|
||||||
const result = await handle.write(data, offset, Math.min(1, length), null);
|
|
||||||
return result.bytesWritten;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await journal.recordIntent('provider-grant');
|
|
||||||
const sealedPath = await journal.seal('ok', 'grant-verified');
|
|
||||||
const records = (await readFile(sealedPath, 'utf8')).trim().split('\n');
|
|
||||||
|
|
||||||
expect(writeCalls).toBeGreaterThan(3);
|
|
||||||
expect(records).toHaveLength(3);
|
|
||||||
expect(records[0]).toContain('"phase":"opened"');
|
|
||||||
expect(records[1]).toContain('"phase":"intent"');
|
|
||||||
expect(records[2]).toContain('"phase":"sealed"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['zero', (_remaining: number): number => 0],
|
|
||||||
['oversized', (remaining: number): number => remaining + 1],
|
|
||||||
] as const)(
|
|
||||||
'rejects %s journal write progress before reporting an opened journal',
|
|
||||||
async (_label, progress): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
await expect(
|
|
||||||
CredentialAuditJournal.open(
|
|
||||||
root,
|
|
||||||
{
|
|
||||||
operation: 'grant',
|
|
||||||
actor: 'provisioner',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `invalid-progress-${_label}`,
|
|
||||||
write: async (_handle, _data, _offset, length): Promise<number> => progress(length),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).rejects.toThrow(/journal-unavailable/);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('keeps a final seal non-accepting while directory durability is pending', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
let directorySyncs = 0;
|
|
||||||
let signalFinalSync: (() => void) | undefined;
|
|
||||||
let releaseFinalSync: (() => void) | undefined;
|
|
||||||
const finalSyncEntered = new Promise<void>((resolve): void => {
|
|
||||||
signalFinalSync = resolve;
|
|
||||||
});
|
|
||||||
const finalSyncRelease = new Promise<void>((resolve): void => {
|
|
||||||
releaseFinalSync = resolve;
|
|
||||||
});
|
|
||||||
const journal = await CredentialAuditJournal.open(
|
|
||||||
root,
|
|
||||||
{
|
|
||||||
operation: 'get',
|
|
||||||
actor: 'seat-name',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'seal-pending',
|
|
||||||
syncDirectory: async (path): Promise<void> => {
|
|
||||||
directorySyncs += 1;
|
|
||||||
if (directorySyncs === 3) {
|
|
||||||
signalFinalSync?.();
|
|
||||||
await finalSyncRelease;
|
|
||||||
}
|
|
||||||
const handle = await open(path, 'r');
|
|
||||||
try {
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const sealing = journal.seal('ok', 'get-verified');
|
|
||||||
await finalSyncEntered;
|
|
||||||
await expect(listCredentialJournals(root)).resolves.toContainEqual(
|
|
||||||
expect.objectContaining({ id: 'seal-pending', state: 'open' }),
|
|
||||||
);
|
|
||||||
releaseFinalSync?.();
|
|
||||||
await sealing;
|
|
||||||
await expect(listCredentialJournals(root)).resolves.toContainEqual(
|
|
||||||
expect.objectContaining({ id: 'seal-pending', state: 'sealed' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reverts a failed final seal commit to visible open state', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
let directorySyncs = 0;
|
|
||||||
const journal = await CredentialAuditJournal.open(
|
|
||||||
root,
|
|
||||||
{
|
|
||||||
operation: 'get',
|
|
||||||
actor: 'seat-name',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'seal-fault',
|
|
||||||
syncDirectory: async (path): Promise<void> => {
|
|
||||||
directorySyncs += 1;
|
|
||||||
if (directorySyncs === 3) throw new Error('injected final directory sync failure');
|
|
||||||
const handle = await open(path, 'r');
|
|
||||||
try {
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(journal.seal('ok', 'get-verified')).rejects.toThrow(/journal-unavailable/);
|
|
||||||
const [entry] = await listCredentialJournals(root);
|
|
||||||
expect(entry).toMatchObject({ id: 'seal-fault', state: 'open' });
|
|
||||||
expect(await readFile(entry?.path ?? '', 'utf8')).not.toContain('"phase":"sealed"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses a non-accepting recovery path when the compensating rename fails', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
let directorySyncs = 0;
|
|
||||||
let renames = 0;
|
|
||||||
const journal = await CredentialAuditJournal.open(
|
|
||||||
root,
|
|
||||||
{
|
|
||||||
operation: 'get',
|
|
||||||
actor: 'seat-name',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'seal-recovery-fault',
|
|
||||||
syncDirectory: async (path): Promise<void> => {
|
|
||||||
directorySyncs += 1;
|
|
||||||
if (directorySyncs === 3) throw new Error('injected final directory sync failure');
|
|
||||||
const handle = await open(path, 'r');
|
|
||||||
try {
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
rename: async (source, destination): Promise<void> => {
|
|
||||||
renames += 1;
|
|
||||||
if (renames === 3) throw new Error('injected compensating rename failure');
|
|
||||||
await rename(source, destination);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(journal.seal('ok', 'get-verified')).rejects.toThrow(/journal-recovery-required/);
|
|
||||||
const [entry] = await listCredentialJournals(root);
|
|
||||||
expect(entry).toMatchObject({ id: 'seal-recovery-fault', state: 'open' });
|
|
||||||
expect(entry?.path).toMatch(/\.recovery\.jsonl$/);
|
|
||||||
expect(await readFile(entry?.path ?? '', 'utf8')).not.toContain('"phase":"sealed"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['symlink', 'oversized'] as const)(
|
|
||||||
'classifies an unsafe %s sealed-looking journal as open without consuming it',
|
|
||||||
async (kind): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
const journal = await CredentialAuditJournal.open(root, {
|
|
||||||
operation: 'audit',
|
|
||||||
actor: 'seat-name',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: null,
|
|
||||||
});
|
|
||||||
await journal.closeIncomplete();
|
|
||||||
const journalsDirectory = join(root, 'journals');
|
|
||||||
const candidate = join(journalsDirectory, `unsafe-${kind}.sealed.jsonl`);
|
|
||||||
if (kind === 'symlink') {
|
|
||||||
if (cleanup === undefined) throw new Error('test fixture root is unavailable');
|
|
||||||
const outside = join(cleanup, 'outside-journal');
|
|
||||||
await writeFile(outside, '{"phase":"sealed","outcome":"ok"}\n', { mode: 0o600 });
|
|
||||||
await symlink(outside, candidate);
|
|
||||||
} else {
|
|
||||||
await writeFile(candidate, '', { mode: 0o600 });
|
|
||||||
await truncate(candidate, 4 * 1024 * 1024 + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = (await listCredentialJournals(root)).find(
|
|
||||||
(value): boolean => value.id === `unsafe-${kind}`,
|
|
||||||
);
|
|
||||||
expect(entry).toMatchObject({ state: 'open' });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('leaves an unsealed journal visible for recovery', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
const journal = await CredentialAuditJournal.open(root, {
|
|
||||||
operation: 'rotate',
|
|
||||||
actor: 'provisioner',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const journals = await listCredentialJournals(root);
|
|
||||||
|
|
||||||
expect(journals).toHaveLength(1);
|
|
||||||
expect(journals[0]?.state).toBe('open');
|
|
||||||
await journal.closeIncomplete();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails fatally when the durable journal root cannot be created', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
await writeFile(root, 'not-a-directory', { mode: 0o600 });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
CredentialAuditJournal.open(root, {
|
|
||||||
operation: 'grant',
|
|
||||||
actor: 'provisioner',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/journal-unavailable/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects secret-shaped evidence instead of writing it', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
const journal = await CredentialAuditJournal.open(root, {
|
|
||||||
operation: 'validate',
|
|
||||||
actor: 'seat-name',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
journal.recordProviderEvidence({
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
decision: 'seeded-secret-canary',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/unsafe-audit-value/);
|
|
||||||
const journals = await listCredentialJournals(root);
|
|
||||||
const source = await readFile(journals[0]?.path ?? '', 'utf8');
|
|
||||||
expect(source).not.toContain('seeded-secret-canary');
|
|
||||||
await journal.closeIncomplete();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a group-writable journal root before opening evidence', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
await mkdir(root, { mode: 0o700 });
|
|
||||||
await chmod(root, 0o770);
|
|
||||||
await expect(
|
|
||||||
CredentialAuditJournal.open(root, {
|
|
||||||
operation: 'grant',
|
|
||||||
actor: 'provisioner',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/journal-unavailable/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supersedes a false sealed classification without editing the original journal', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
const journal = await CredentialAuditJournal.open(
|
|
||||||
root,
|
|
||||||
{
|
|
||||||
operation: 'validate',
|
|
||||||
actor: 'be-coder-06',
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
},
|
|
||||||
{ id: 'correction-1' },
|
|
||||||
);
|
|
||||||
await journal.recordIntent('classification-correction');
|
|
||||||
await journal.recordCorrection({
|
|
||||||
supersedesJournalId: 'old-sealed-id',
|
|
||||||
correctedByJournalId: 'new-validation-id',
|
|
||||||
previousReason: 'identity-not-found',
|
|
||||||
correctedReason: 'credential-rejected',
|
|
||||||
previousOutcome: 'indeterminate',
|
|
||||||
correctedOutcome: 'refused',
|
|
||||||
});
|
|
||||||
const path = await journal.seal('indeterminate', 'credential-rejected');
|
|
||||||
const source = await readFile(path, 'utf8');
|
|
||||||
expect(source).toContain('"phase":"classification-correction"');
|
|
||||||
expect(source).toContain('"supersedesJournalId":"old-sealed-id"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('records one settled population correction across a classification chain', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
const journal = await CredentialAuditJournal.open(root, {
|
|
||||||
operation: 'validate',
|
|
||||||
actor: 'be-coder-06',
|
|
||||||
identity: 'fleet-reconciliation',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
});
|
|
||||||
await journal.recordIntent('classification-correction');
|
|
||||||
await journal.recordPopulationCorrection({
|
|
||||||
entries: [
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
supersedesJournalIds: ['v1-2-id', 'v1-4-id', 'v1-4-1-id'],
|
|
||||||
settledByJournalId: 'v1-5-id',
|
|
||||||
capability: 'confirmed',
|
|
||||||
identityBinding: 'not-measured',
|
|
||||||
mechanism: 'identity-scope-forbidden-in-scope-capability-confirmed',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const path = await journal.seal('ok', 'classification-corrected');
|
|
||||||
const source = await readFile(path, 'utf8');
|
|
||||||
expect(source).toContain('"phase":"population-classification-correction"');
|
|
||||||
expect(source).toContain('"capability":"confirmed"');
|
|
||||||
expect(source).toContain('"identityBinding":"not-measured"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,467 +0,0 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { constants, lstatSync } from 'node:fs';
|
|
||||||
import { open, readdir, rename } from 'node:fs/promises';
|
|
||||||
import type { FileHandle } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
|
|
||||||
import type {
|
|
||||||
CredentialJournalContextDto,
|
|
||||||
CredentialJournalCorrectionDto,
|
|
||||||
CredentialJournalRuntimeOptionsDto,
|
|
||||||
CredentialPopulationCorrectionDto,
|
|
||||||
CredentialJournalSummaryDto,
|
|
||||||
CredentialProviderJournalEvidenceDto,
|
|
||||||
} from './audit-journal.dto.js';
|
|
||||||
|
|
||||||
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
||||||
const SAFE_ESTATE = /^[a-z0-9][a-z0-9-]*$/;
|
|
||||||
const SAFE_HOST = /^[a-z0-9][a-z0-9.-]*$/;
|
|
||||||
const SAFE_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
||||||
const SAFE_ENDPOINT = /^(?:GET|PUT|POST|DELETE) \/[A-Za-z0-9_./{}:-]+$/;
|
|
||||||
const SAFE_CONTENT_TYPE = /^[A-Za-z0-9!#$&^_.+/-]+(?:;[A-Za-z0-9=._+-]+)*$/;
|
|
||||||
const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
|
|
||||||
const SAFE_DECISIONS = new Set<string>([
|
|
||||||
'provider-grant',
|
|
||||||
'permission-none',
|
|
||||||
'permission-read',
|
|
||||||
'permission-write',
|
|
||||||
'permission-admin',
|
|
||||||
'identity-verified',
|
|
||||||
'inventory-authority-verified',
|
|
||||||
'scope-verified',
|
|
||||||
'grant-verified',
|
|
||||||
'revoke-verified',
|
|
||||||
'rotate-verified',
|
|
||||||
'validation-requested',
|
|
||||||
'whoami-requested',
|
|
||||||
'provision-requested',
|
|
||||||
'rotate-requested',
|
|
||||||
'revoke-requested',
|
|
||||||
'wire-requested',
|
|
||||||
'get-requested',
|
|
||||||
'validation-verified',
|
|
||||||
'team-member-present',
|
|
||||||
'team-member-absent',
|
|
||||||
'team-repository-present',
|
|
||||||
'team-repository-absent',
|
|
||||||
'team-repository-set-verified',
|
|
||||||
'organization-member-present',
|
|
||||||
'organization-member-absent',
|
|
||||||
'collaborator-grant-applied',
|
|
||||||
'team-member-applied',
|
|
||||||
'team-member-rollback-applied',
|
|
||||||
'team-repository-applied',
|
|
||||||
'team-repository-rollback-applied',
|
|
||||||
'transport-write-verified',
|
|
||||||
'token-mint-applied',
|
|
||||||
'token-binding-stored',
|
|
||||||
'tea-login-stored',
|
|
||||||
'tea-login-removed',
|
|
||||||
'provision-rollback-verified',
|
|
||||||
'rotate-rollback-verified',
|
|
||||||
'token-revoke-applied',
|
|
||||||
'wire-applied',
|
|
||||||
'credential-issuance-authorized',
|
|
||||||
'credential-issuance-started',
|
|
||||||
'credential-issuance-possibly-issued',
|
|
||||||
'credential-issued',
|
|
||||||
'classification-correction',
|
|
||||||
]);
|
|
||||||
|
|
||||||
export class CredentialJournalError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly code: string,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(`Credential audit journal failed: code=${code} ${message}`);
|
|
||||||
this.name = 'CredentialJournalError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPrivateDirectory(path: string): void {
|
|
||||||
const stat = lstatSync(path);
|
|
||||||
if (
|
|
||||||
!stat.isDirectory() ||
|
|
||||||
stat.isSymbolicLink() ||
|
|
||||||
stat.uid !== process.getuid?.() ||
|
|
||||||
(stat.mode & 0o022) !== 0
|
|
||||||
) {
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'journal-unavailable',
|
|
||||||
'journal directory owner or write permissions are unsafe',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertContext(context: CredentialJournalContextDto): void {
|
|
||||||
if (
|
|
||||||
!SAFE_NAME.test(context.actor) ||
|
|
||||||
!SAFE_NAME.test(context.identity) ||
|
|
||||||
!SAFE_ESTATE.test(context.estate) ||
|
|
||||||
!SAFE_HOST.test(context.host) ||
|
|
||||||
(context.repo !== null && !SAFE_REPO.test(context.repo))
|
|
||||||
) {
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'unsafe-audit-value',
|
|
||||||
'journal context is outside the non-secret allowlist grammar',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertEvidence(evidence: CredentialProviderJournalEvidenceDto): void {
|
|
||||||
if (
|
|
||||||
!SAFE_ENDPOINT.test(evidence.endpoint) ||
|
|
||||||
!SAFE_CONTENT_TYPE.test(evidence.contentType) ||
|
|
||||||
!SAFE_DECISIONS.has(evidence.decision)
|
|
||||||
) {
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'unsafe-audit-value',
|
|
||||||
'provider evidence is outside the non-secret allowlist',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeJournalBytes(
|
|
||||||
handle: FileHandle,
|
|
||||||
data: Uint8Array,
|
|
||||||
offset: number,
|
|
||||||
length: number,
|
|
||||||
): Promise<number> {
|
|
||||||
const result = await handle.write(data, offset, length, null);
|
|
||||||
return result.bytesWritten;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncDirectory(path: string): Promise<void> {
|
|
||||||
const directory = await open(path, 'r');
|
|
||||||
try {
|
|
||||||
await directory.sync();
|
|
||||||
} finally {
|
|
||||||
await directory.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acquireJournalLock(
|
|
||||||
path: string,
|
|
||||||
mode: 'exclusive' | 'shared',
|
|
||||||
): Promise<FileHandle | undefined> {
|
|
||||||
let handle: FileHandle | undefined;
|
|
||||||
try {
|
|
||||||
handle = await open(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
|
|
||||||
const stat = await handle.stat();
|
|
||||||
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) {
|
|
||||||
throw new Error('journal lock file is unsafe');
|
|
||||||
}
|
|
||||||
const acquired = spawnSync('/usr/bin/flock', ['-n', mode === 'exclusive' ? '-x' : '-s', '3'], {
|
|
||||||
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
|
|
||||||
});
|
|
||||||
if (acquired.error !== undefined || acquired.status !== 0) {
|
|
||||||
await handle.close();
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return handle;
|
|
||||||
} catch {
|
|
||||||
await handle?.close().catch((): void => undefined);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CredentialAuditJournal {
|
|
||||||
private closed = false;
|
|
||||||
|
|
||||||
private constructor(
|
|
||||||
private readonly handle: FileHandle,
|
|
||||||
private readonly lockHandle: FileHandle,
|
|
||||||
private readonly openPath: string,
|
|
||||||
private readonly journalsDirectory: string,
|
|
||||||
private readonly id: string,
|
|
||||||
private readonly now: () => string,
|
|
||||||
private readonly syncJournalDirectory: (path: string) => Promise<void>,
|
|
||||||
private readonly renameJournal: (source: string, destination: string) => Promise<void>,
|
|
||||||
private readonly writeJournal: (
|
|
||||||
handle: FileHandle,
|
|
||||||
data: Uint8Array,
|
|
||||||
offset: number,
|
|
||||||
length: number,
|
|
||||||
) => Promise<number>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
static async open(
|
|
||||||
stateRoot: string,
|
|
||||||
context: CredentialJournalContextDto,
|
|
||||||
runtime: CredentialJournalRuntimeOptionsDto = {},
|
|
||||||
): Promise<CredentialAuditJournal> {
|
|
||||||
assertContext(context);
|
|
||||||
const id = runtime.id ?? randomUUID();
|
|
||||||
if (!SAFE_NAME.test(id)) {
|
|
||||||
throw new CredentialJournalError('unsafe-audit-value', 'journal id is outside the grammar');
|
|
||||||
}
|
|
||||||
const now = runtime.now ?? ((): string => new Date().toISOString());
|
|
||||||
const journalsDirectory = join(stateRoot, 'journals');
|
|
||||||
const locksDirectory = join(stateRoot, 'journal-locks');
|
|
||||||
let handle: FileHandle | undefined;
|
|
||||||
let lockHandle: FileHandle | undefined;
|
|
||||||
try {
|
|
||||||
ensureManagedDirectory(stateRoot, journalsDirectory);
|
|
||||||
ensureManagedDirectory(stateRoot, locksDirectory);
|
|
||||||
assertPrivateDirectory(stateRoot);
|
|
||||||
assertPrivateDirectory(journalsDirectory);
|
|
||||||
assertPrivateDirectory(locksDirectory);
|
|
||||||
const openPath = join(journalsDirectory, `${id}.open.jsonl`);
|
|
||||||
const lockPath = join(locksDirectory, `${id}.lock`);
|
|
||||||
handle = await open(openPath, 'wx', 0o600);
|
|
||||||
lockHandle = await acquireJournalLock(lockPath, 'exclusive');
|
|
||||||
if (lockHandle === undefined) {
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'journal-unavailable',
|
|
||||||
'journal lock could not be acquired',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const syncJournalDirectory = runtime.syncDirectory ?? syncDirectory;
|
|
||||||
const renameJournal = runtime.rename ?? rename;
|
|
||||||
const journal = new CredentialAuditJournal(
|
|
||||||
handle,
|
|
||||||
lockHandle,
|
|
||||||
openPath,
|
|
||||||
journalsDirectory,
|
|
||||||
id,
|
|
||||||
now,
|
|
||||||
syncJournalDirectory,
|
|
||||||
renameJournal,
|
|
||||||
runtime.write ?? writeJournalBytes,
|
|
||||||
);
|
|
||||||
await journal.append({ phase: 'opened', at: now(), context });
|
|
||||||
await syncJournalDirectory(journalsDirectory);
|
|
||||||
return journal;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (handle !== undefined) await handle.close().catch((): void => undefined);
|
|
||||||
if (lockHandle !== undefined) await lockHandle.close().catch((): void => undefined);
|
|
||||||
if (error instanceof CredentialJournalError) throw error;
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'journal-unavailable',
|
|
||||||
'durable journal could not be opened and fsynced',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async append(record: object): Promise<void> {
|
|
||||||
if (this.closed) {
|
|
||||||
throw new CredentialJournalError('journal-unavailable', 'journal is already closed');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = Buffer.from(`${JSON.stringify(record)}\n`, 'utf8');
|
|
||||||
let offset = 0;
|
|
||||||
while (offset < data.byteLength) {
|
|
||||||
const remaining = data.byteLength - offset;
|
|
||||||
const written = await this.writeJournal(this.handle, data, offset, remaining);
|
|
||||||
if (!Number.isSafeInteger(written) || written <= 0 || written > remaining) {
|
|
||||||
throw new Error('journal write made invalid progress');
|
|
||||||
}
|
|
||||||
offset += written;
|
|
||||||
}
|
|
||||||
await this.handle.sync();
|
|
||||||
} catch {
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'journal-unavailable',
|
|
||||||
'durable journal append or fsync failed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
journalId(): string {
|
|
||||||
return this.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordIntent(decision: string): Promise<void> {
|
|
||||||
if (!SAFE_DECISIONS.has(decision)) {
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'unsafe-audit-value',
|
|
||||||
'intent decision is outside the non-secret allowlist',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.append({ phase: 'intent', at: this.now(), decision });
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordProviderEvidence(evidence: CredentialProviderJournalEvidenceDto): Promise<void> {
|
|
||||||
assertEvidence(evidence);
|
|
||||||
await this.append({ phase: 'provider-evidence', at: this.now(), evidence });
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordMutation(decision: string): Promise<void> {
|
|
||||||
if (!SAFE_DECISIONS.has(decision)) {
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'unsafe-audit-value',
|
|
||||||
'mutation decision is outside the non-secret allowlist',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.append({ phase: 'mutation', at: this.now(), decision });
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordCorrection(correction: CredentialJournalCorrectionDto): Promise<void> {
|
|
||||||
if (
|
|
||||||
!SAFE_NAME.test(correction.supersedesJournalId) ||
|
|
||||||
!SAFE_NAME.test(correction.correctedByJournalId) ||
|
|
||||||
!SAFE_NAME.test(correction.previousReason) ||
|
|
||||||
!SAFE_NAME.test(correction.correctedReason) ||
|
|
||||||
(correction.previousOutcome === undefined) !== (correction.correctedOutcome === undefined)
|
|
||||||
) {
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'unsafe-audit-value',
|
|
||||||
'classification correction is outside the non-secret grammar',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.append({ phase: 'classification-correction', at: this.now(), correction });
|
|
||||||
}
|
|
||||||
|
|
||||||
async recordPopulationCorrection(correction: CredentialPopulationCorrectionDto): Promise<void> {
|
|
||||||
if (
|
|
||||||
correction.entries.length === 0 ||
|
|
||||||
correction.entries.some(
|
|
||||||
(entry): boolean =>
|
|
||||||
!SAFE_NAME.test(entry.identity) ||
|
|
||||||
!SAFE_NAME.test(entry.settledByJournalId) ||
|
|
||||||
entry.supersedesJournalIds.length === 0 ||
|
|
||||||
entry.supersedesJournalIds.some((id): boolean => !SAFE_NAME.test(id)),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'unsafe-audit-value',
|
|
||||||
'population correction is outside the non-secret grammar',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.append({
|
|
||||||
phase: 'population-classification-correction',
|
|
||||||
at: this.now(),
|
|
||||||
correction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async seal(
|
|
||||||
outcome: 'ok' | 'refused' | 'error' | 'indeterminate',
|
|
||||||
reasonCode: string,
|
|
||||||
): Promise<string> {
|
|
||||||
if (!SAFE_NAME.test(reasonCode)) {
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
'unsafe-audit-value',
|
|
||||||
'reason code is outside the non-secret grammar',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const preSealSize = (await this.handle.stat()).size;
|
|
||||||
const sealingPath = join(this.journalsDirectory, `${this.id}.sealing.jsonl`);
|
|
||||||
const sealedPath = join(this.journalsDirectory, `${this.id}.sealed.jsonl`);
|
|
||||||
const recoveryPath = join(this.journalsDirectory, `${this.id}.recovery.jsonl`);
|
|
||||||
let currentPath = this.openPath;
|
|
||||||
try {
|
|
||||||
await this.renameJournal(currentPath, sealingPath);
|
|
||||||
currentPath = sealingPath;
|
|
||||||
await this.syncJournalDirectory(this.journalsDirectory);
|
|
||||||
await this.append({ phase: 'sealed', at: this.now(), outcome, reasonCode });
|
|
||||||
await this.renameJournal(currentPath, sealedPath);
|
|
||||||
currentPath = sealedPath;
|
|
||||||
await this.syncJournalDirectory(this.journalsDirectory);
|
|
||||||
await this.handle.close();
|
|
||||||
await this.lockHandle.close();
|
|
||||||
this.closed = true;
|
|
||||||
return sealedPath;
|
|
||||||
} catch {
|
|
||||||
let recovered = true;
|
|
||||||
try {
|
|
||||||
await this.handle.truncate(preSealSize);
|
|
||||||
await this.handle.sync();
|
|
||||||
} catch {
|
|
||||||
recovered = false;
|
|
||||||
}
|
|
||||||
if (currentPath !== this.openPath) {
|
|
||||||
try {
|
|
||||||
await this.renameJournal(currentPath, this.openPath);
|
|
||||||
currentPath = this.openPath;
|
|
||||||
} catch {
|
|
||||||
recovered = false;
|
|
||||||
try {
|
|
||||||
await this.renameJournal(currentPath, recoveryPath);
|
|
||||||
currentPath = recoveryPath;
|
|
||||||
} catch {
|
|
||||||
// The non-success return below remains authoritative; the path is reported by audit scan.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this.syncJournalDirectory(this.journalsDirectory);
|
|
||||||
} catch {
|
|
||||||
recovered = false;
|
|
||||||
}
|
|
||||||
await this.handle.close().catch((): void => undefined);
|
|
||||||
await this.lockHandle.close().catch((): void => undefined);
|
|
||||||
this.closed = true;
|
|
||||||
throw new CredentialJournalError(
|
|
||||||
recovered ? 'journal-unavailable' : 'journal-recovery-required',
|
|
||||||
'sealed journal could not be committed durably',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeIncomplete(): Promise<void> {
|
|
||||||
if (this.closed) return;
|
|
||||||
await this.handle.close();
|
|
||||||
await this.lockHandle.close();
|
|
||||||
this.closed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listCredentialJournals(
|
|
||||||
stateRoot: string,
|
|
||||||
): Promise<readonly CredentialJournalSummaryDto[]> {
|
|
||||||
const journalsDirectory = join(stateRoot, 'journals');
|
|
||||||
const locksDirectory = join(stateRoot, 'journal-locks');
|
|
||||||
let names: string[];
|
|
||||||
try {
|
|
||||||
assertPrivateDirectory(stateRoot);
|
|
||||||
assertPrivateDirectory(journalsDirectory);
|
|
||||||
ensureManagedDirectory(stateRoot, locksDirectory);
|
|
||||||
assertPrivateDirectory(locksDirectory);
|
|
||||||
names = await readdir(journalsDirectory);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return [];
|
|
||||||
throw new CredentialJournalError('journal-unavailable', 'journal directory could not be read');
|
|
||||||
}
|
|
||||||
return Promise.all(
|
|
||||||
names
|
|
||||||
.filter((name: string): boolean => /\.(?:open|sealing|recovery|sealed)\.jsonl$/.test(name))
|
|
||||||
.sort()
|
|
||||||
.map(async (name: string): Promise<CredentialJournalSummaryDto> => {
|
|
||||||
const path = join(journalsDirectory, name);
|
|
||||||
const id = name.replace(/\.(?:open|sealing|recovery|sealed)\.jsonl$/, '');
|
|
||||||
let committedSeal = false;
|
|
||||||
let scanLock: FileHandle | undefined;
|
|
||||||
if (name.endsWith('.sealed.jsonl') && SAFE_NAME.test(id)) {
|
|
||||||
try {
|
|
||||||
scanLock = await acquireJournalLock(join(locksDirectory, `${id}.lock`), 'shared');
|
|
||||||
if (scanLock === undefined) throw new Error('journal seal is still in progress');
|
|
||||||
const snapshot = readRegularFileSecure(path, {
|
|
||||||
root: journalsDirectory,
|
|
||||||
maxBytes: MAX_JOURNAL_BYTES,
|
|
||||||
});
|
|
||||||
if (snapshot.uid !== process.getuid?.() || (snapshot.mode & 0o077) !== 0) {
|
|
||||||
throw new Error('journal owner or mode is unsafe');
|
|
||||||
}
|
|
||||||
const records = snapshot.content.toString('utf8').trim().split('\n');
|
|
||||||
const finalRecord: unknown = JSON.parse(records.at(-1) ?? 'null');
|
|
||||||
committedSeal =
|
|
||||||
typeof finalRecord === 'object' &&
|
|
||||||
finalRecord !== null &&
|
|
||||||
'phase' in finalRecord &&
|
|
||||||
finalRecord.phase === 'sealed';
|
|
||||||
} catch {
|
|
||||||
committedSeal = false;
|
|
||||||
} finally {
|
|
||||||
await scanLock?.close().catch((): void => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
state: committedSeal ? 'sealed' : 'open',
|
|
||||||
path,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export interface CredentialBindingMetadataDto {
|
|
||||||
readonly schemaVersion?: 1;
|
|
||||||
readonly identity: string;
|
|
||||||
readonly estate: string;
|
|
||||||
readonly host: string;
|
|
||||||
readonly providerLogin: string;
|
|
||||||
readonly tokenName: string;
|
|
||||||
readonly scopes: readonly string[];
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly tokenDigest?: string;
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import type {
|
|
||||||
ProviderIdentityEvidenceDto,
|
|
||||||
ReceivePackEvidenceDto,
|
|
||||||
RepositoryPermission,
|
|
||||||
RepositoryPermissionEvidenceDto,
|
|
||||||
} from './credential-result.dto.js';
|
|
||||||
|
|
||||||
export interface ResolvedCredential {
|
|
||||||
readonly identity: string;
|
|
||||||
readonly estate: string;
|
|
||||||
readonly host: string;
|
|
||||||
readonly resolutionId: string;
|
|
||||||
readonly secret: Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialResolver {
|
|
||||||
resolve(identity: string, estate: string, host: string): Promise<ResolvedCredential | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GiteaCredentialProvider {
|
|
||||||
readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto>;
|
|
||||||
readRepositoryPermission(
|
|
||||||
resolved: ResolvedCredential,
|
|
||||||
repo: string,
|
|
||||||
): Promise<RepositoryPermissionEvidenceDto>;
|
|
||||||
probeReceivePack(
|
|
||||||
resolved: ResolvedCredential | undefined,
|
|
||||||
repo: string,
|
|
||||||
): Promise<ReceivePackEvidenceDto>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialEstateRegistry {
|
|
||||||
matches(estate: string, host: string): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialValidationDependencies {
|
|
||||||
readonly resolver: CredentialResolver;
|
|
||||||
readonly provider: GiteaCredentialProvider;
|
|
||||||
readonly estateRegistry: CredentialEstateRegistry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GiteaReadValidationRequestDto {
|
|
||||||
readonly identity: string;
|
|
||||||
readonly estate: string;
|
|
||||||
readonly host: string;
|
|
||||||
readonly repo: string;
|
|
||||||
readonly requiredPermission?: RepositoryPermission;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GiteaWriteValidationRequestDto extends GiteaReadValidationRequestDto {
|
|
||||||
readonly readOnlyControlIdentity: string;
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate';
|
|
||||||
export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown';
|
|
||||||
export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin';
|
|
||||||
export type ReceivePackState = 'advertised' | 'refused';
|
|
||||||
|
|
||||||
export interface CredentialReasonDto {
|
|
||||||
readonly code: string;
|
|
||||||
readonly message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialSubjectDto {
|
|
||||||
readonly identity: string;
|
|
||||||
readonly estate: string;
|
|
||||||
readonly host: string;
|
|
||||||
readonly repo: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderIdentityEvidenceDto {
|
|
||||||
readonly login: string;
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RepositoryPermissionEvidenceDto {
|
|
||||||
readonly effective: RepositoryPermission;
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReceivePackEvidenceDto {
|
|
||||||
readonly state: ReceivePackState;
|
|
||||||
readonly principal: string | null;
|
|
||||||
readonly resolutionId: string | null;
|
|
||||||
readonly contentType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReadOnlyControlEvidenceDto {
|
|
||||||
readonly identity: string;
|
|
||||||
readonly providerPermission: RepositoryPermission;
|
|
||||||
readonly receivePack: ReceivePackState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WriteDifferentialEvidenceDto {
|
|
||||||
readonly state: 'can-write';
|
|
||||||
readonly credentialBinding: 'same-resolution';
|
|
||||||
readonly transportPrincipal: string;
|
|
||||||
readonly authenticatedReceivePack: 'advertised';
|
|
||||||
readonly readOnlyControl: ReadOnlyControlEvidenceDto;
|
|
||||||
readonly unauthenticatedReceivePack: 'refused';
|
|
||||||
readonly artifactCreated: false;
|
|
||||||
readonly proves: string;
|
|
||||||
readonly doesNotProve: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenCapabilitiesEvidenceDto {
|
|
||||||
readonly state: 'measured' | 'not-measured';
|
|
||||||
readonly scopes: readonly string[];
|
|
||||||
readonly source: 'provider-token-object' | 'runtime-not-authorized';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialValidationEvidenceDto {
|
|
||||||
readonly providerIdentity: ProviderIdentityEvidenceDto | null;
|
|
||||||
readonly tokenCapabilities: TokenCapabilitiesEvidenceDto;
|
|
||||||
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
|
|
||||||
readonly writeDifferential: WriteDifferentialEvidenceDto | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialAuditResultDto {
|
|
||||||
readonly journalId: string | null;
|
|
||||||
readonly state: 'not-started' | 'open' | 'sealed';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialValidationResultDto {
|
|
||||||
readonly schemaVersion: 1;
|
|
||||||
readonly operation: 'validate' | 'whoami';
|
|
||||||
readonly outcome: CredentialOutcome;
|
|
||||||
readonly exitCode: 0 | 10 | 20 | 30;
|
|
||||||
readonly retryable: boolean;
|
|
||||||
readonly subject: CredentialSubjectDto;
|
|
||||||
readonly mutation: CredentialMutationState;
|
|
||||||
readonly reason: CredentialReasonDto;
|
|
||||||
readonly evidence: CredentialValidationEvidenceDto;
|
|
||||||
readonly audit: CredentialAuditResultDto;
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
|
||||||
import type {
|
|
||||||
CredentialValidationDependencies,
|
|
||||||
GiteaReadValidationRequestDto,
|
|
||||||
GiteaWriteValidationRequestDto,
|
|
||||||
} from './credential-provider.dto.js';
|
|
||||||
import type {
|
|
||||||
CredentialValidationResultDto,
|
|
||||||
RepositoryPermission,
|
|
||||||
} from './credential-result.dto.js';
|
|
||||||
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
|
||||||
|
|
||||||
export interface CredentialValidationServiceOptions {
|
|
||||||
readonly stateRoot: string;
|
|
||||||
readonly actor: string;
|
|
||||||
readonly operation?: 'validate' | 'whoami';
|
|
||||||
}
|
|
||||||
|
|
||||||
function permissionDecision(permission: RepositoryPermission): string {
|
|
||||||
if (permission === 'none') return 'permission-none';
|
|
||||||
if (permission === 'admin') return 'permission-admin';
|
|
||||||
if (permission === 'write') return 'permission-write';
|
|
||||||
return 'permission-read';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openValidationJournal(
|
|
||||||
request: GiteaReadValidationRequestDto,
|
|
||||||
options: CredentialValidationServiceOptions,
|
|
||||||
): Promise<CredentialAuditJournal> {
|
|
||||||
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
|
||||||
operation: options.operation ?? 'validate',
|
|
||||||
actor: options.actor,
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
repo: request.repo,
|
|
||||||
});
|
|
||||||
await journal.recordIntent(
|
|
||||||
options.operation === 'whoami' ? 'whoami-requested' : 'validation-requested',
|
|
||||||
);
|
|
||||||
return journal;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recordAndSealValidation(
|
|
||||||
journal: CredentialAuditJournal,
|
|
||||||
validation: CredentialValidationResultDto,
|
|
||||||
): Promise<CredentialValidationResultDto> {
|
|
||||||
if (validation.evidence.providerIdentity !== null) {
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: validation.evidence.providerIdentity.endpoint,
|
|
||||||
contentType: validation.evidence.providerIdentity.contentType,
|
|
||||||
decision: 'identity-verified',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (validation.evidence.repositoryPermission !== null) {
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: validation.evidence.repositoryPermission.endpoint,
|
|
||||||
contentType: validation.evidence.repositoryPermission.contentType,
|
|
||||||
decision: permissionDecision(validation.evidence.repositoryPermission.effective),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await journal.seal(validation.outcome, validation.reason.code);
|
|
||||||
return {
|
|
||||||
...validation,
|
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function journalFailureResult(
|
|
||||||
request: GiteaReadValidationRequestDto,
|
|
||||||
journal: CredentialAuditJournal,
|
|
||||||
error: CredentialJournalError,
|
|
||||||
operation: 'validate' | 'whoami',
|
|
||||||
): CredentialValidationResultDto {
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
operation,
|
|
||||||
outcome: 'error',
|
|
||||||
exitCode: 20,
|
|
||||||
retryable: false,
|
|
||||||
subject: {
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
repo: request.repo,
|
|
||||||
},
|
|
||||||
mutation: 'none',
|
|
||||||
reason: {
|
|
||||||
code: error.code,
|
|
||||||
message: 'Validation audit persistence failed; inspect the durable open journal.',
|
|
||||||
},
|
|
||||||
evidence: {
|
|
||||||
providerIdentity: null,
|
|
||||||
tokenCapabilities: {
|
|
||||||
state: 'not-measured',
|
|
||||||
scopes: [],
|
|
||||||
source: 'runtime-not-authorized',
|
|
||||||
},
|
|
||||||
repositoryPermission: null,
|
|
||||||
writeDifferential: null,
|
|
||||||
},
|
|
||||||
audit: { journalId: journal.journalId(), state: 'open' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runCredentialReadValidation(
|
|
||||||
request: GiteaReadValidationRequestDto,
|
|
||||||
dependencies: CredentialValidationDependencies,
|
|
||||||
options: CredentialValidationServiceOptions,
|
|
||||||
): Promise<CredentialValidationResultDto> {
|
|
||||||
const journal = await openValidationJournal(request, options);
|
|
||||||
try {
|
|
||||||
const validation = await evaluateGiteaReadValidation(request, dependencies);
|
|
||||||
return await recordAndSealValidation(journal, {
|
|
||||||
...validation,
|
|
||||||
operation: options.operation ?? 'validate',
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof CredentialJournalError) {
|
|
||||||
return journalFailureResult(request, journal, error, options.operation ?? 'validate');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runCredentialValidation(
|
|
||||||
request: GiteaWriteValidationRequestDto,
|
|
||||||
dependencies: CredentialValidationDependencies,
|
|
||||||
options: CredentialValidationServiceOptions,
|
|
||||||
): Promise<CredentialValidationResultDto> {
|
|
||||||
const journal = await openValidationJournal(request, options);
|
|
||||||
try {
|
|
||||||
const validation = await evaluateGiteaWriteValidation(request, dependencies);
|
|
||||||
return await recordAndSealValidation(journal, validation);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof CredentialJournalError) {
|
|
||||||
return journalFailureResult(request, journal, error, 'validate');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { mkdtemp, open, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import { readDelegatedCredentialFromFd } from './delegated-credential.js';
|
|
||||||
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('protected delegated credential channel', (): void => {
|
|
||||||
it('reads authority from an inherited fd number without putting the secret in argv or env', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
|
|
||||||
const path = join(cleanup, 'authority');
|
|
||||||
await writeFile(
|
|
||||||
path,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'provisioner',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'seeded-authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
const handle = await open(path, 'r');
|
|
||||||
try {
|
|
||||||
const resolved = await readDelegatedCredentialFromFd(
|
|
||||||
handle.fd,
|
|
||||||
'provisioner',
|
|
||||||
'homelab',
|
|
||||||
'git.example.invalid',
|
|
||||||
);
|
|
||||||
expect(resolved.identity).toBe('provisioner');
|
|
||||||
expect(Buffer.from(resolved.secret).toString('utf8')).toBe('seeded-authority-canary');
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a regular-file authority fd with group or other access', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
|
|
||||||
const path = join(cleanup, 'authority');
|
|
||||||
await writeFile(
|
|
||||||
path,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'provisioner',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'seeded-authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o644 },
|
|
||||||
);
|
|
||||||
const handle = await open(path, 'r');
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
readDelegatedCredentialFromFd(handle.fd, 'provisioner', 'homelab', 'git.example.invalid'),
|
|
||||||
).rejects.toMatchObject({ code: 'delegated-authority-unavailable' });
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an authority identity or estate mismatch without echoing the secret', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-authority-fd-'));
|
|
||||||
const path = join(cleanup, 'authority');
|
|
||||||
await writeFile(
|
|
||||||
path,
|
|
||||||
JSON.stringify({
|
|
||||||
identity: 'other',
|
|
||||||
estate: 'usc',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
secret: 'seeded-authority-canary',
|
|
||||||
}),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
const handle = await open(path, 'r');
|
|
||||||
try {
|
|
||||||
let message = '';
|
|
||||||
try {
|
|
||||||
await readDelegatedCredentialFromFd(
|
|
||||||
handle.fd,
|
|
||||||
'provisioner',
|
|
||||||
'homelab',
|
|
||||||
'git.example.invalid',
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
message = error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
expect(message).toContain('delegated-authority-mismatch');
|
|
||||||
expect(message).not.toContain('seeded-authority-canary');
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { createReadStream, fstatSync } from 'node:fs';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
||||||
|
|
||||||
const authoritySchema = z
|
|
||||||
.object({
|
|
||||||
identity: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*$/),
|
|
||||||
estate: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
||||||
host: z.string().regex(/^[a-z0-9][a-z0-9.-]*$/),
|
|
||||||
secret: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(16 * 1024)
|
|
||||||
.regex(/^\S+$/),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export class DelegatedCredentialError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly code: string,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(`Delegated credential rejected: code=${code} ${message}`);
|
|
||||||
this.name = 'DelegatedCredentialError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readProtectedFd(fd: number): Promise<Buffer> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout((): void => controller.abort(), 5_000);
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
let total = 0;
|
|
||||||
try {
|
|
||||||
const stream = createReadStream(`/proc/self/fd/${fd}`, {
|
|
||||||
highWaterMark: 4 * 1024,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
||||||
total += bytes.byteLength;
|
|
||||||
if (total > 32 * 1024) {
|
|
||||||
stream.destroy();
|
|
||||||
throw new Error('protected credential payload exceeded the bound');
|
|
||||||
}
|
|
||||||
chunks.push(bytes);
|
|
||||||
}
|
|
||||||
return Buffer.concat(chunks, total);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readDelegatedCredentialFromFd(
|
|
||||||
fd: number,
|
|
||||||
expectedIdentity: string,
|
|
||||||
expectedEstate: string,
|
|
||||||
expectedHost: string,
|
|
||||||
): Promise<ResolvedCredential> {
|
|
||||||
if (!Number.isSafeInteger(fd) || fd < 3 || fd > 1024) {
|
|
||||||
throw new DelegatedCredentialError('delegated-authority-unavailable', 'invalid inherited fd');
|
|
||||||
}
|
|
||||||
let bytes: Buffer;
|
|
||||||
try {
|
|
||||||
const stat = fstatSync(fd);
|
|
||||||
if (!stat.isFile() && !stat.isFIFO()) {
|
|
||||||
throw new Error('fd is not a regular file or pipe');
|
|
||||||
}
|
|
||||||
const currentUid = process.getuid?.();
|
|
||||||
if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) {
|
|
||||||
throw new Error('fd owner or permissions are unsafe');
|
|
||||||
}
|
|
||||||
bytes = await readProtectedFd(fd);
|
|
||||||
} catch {
|
|
||||||
throw new DelegatedCredentialError(
|
|
||||||
'delegated-authority-unavailable',
|
|
||||||
'protected inherited credential fd could not be read',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (bytes.byteLength > 32 * 1024) {
|
|
||||||
bytes.fill(0);
|
|
||||||
throw new DelegatedCredentialError(
|
|
||||||
'delegated-authority-unavailable',
|
|
||||||
'protected credential payload exceeded the bound',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let raw: unknown;
|
|
||||||
try {
|
|
||||||
raw = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
||||||
} catch {
|
|
||||||
bytes.fill(0);
|
|
||||||
throw new DelegatedCredentialError(
|
|
||||||
'delegated-authority-unavailable',
|
|
||||||
'protected credential payload was invalid',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
bytes.fill(0);
|
|
||||||
const parsed = authoritySchema.safeParse(raw);
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new DelegatedCredentialError(
|
|
||||||
'delegated-authority-unavailable',
|
|
||||||
'protected credential payload did not match the schema',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
parsed.data.identity !== expectedIdentity ||
|
|
||||||
parsed.data.estate !== expectedEstate ||
|
|
||||||
parsed.data.host !== expectedHost
|
|
||||||
) {
|
|
||||||
throw new DelegatedCredentialError(
|
|
||||||
'delegated-authority-mismatch',
|
|
||||||
'protected credential does not match the explicit actor, estate, and host',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Object.freeze({
|
|
||||||
identity: parsed.data.identity,
|
|
||||||
estate: parsed.data.estate,
|
|
||||||
host: parsed.data.host,
|
|
||||||
resolutionId: randomUUID(),
|
|
||||||
secret: new TextEncoder().encode(parsed.data.secret),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export type CredentialProviderKind = 'gitea';
|
|
||||||
|
|
||||||
export interface CredentialHostConfigDto {
|
|
||||||
readonly host: string;
|
|
||||||
readonly provider: CredentialProviderKind;
|
|
||||||
readonly apiBaseUrl: string;
|
|
||||||
readonly tokenPrefix: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialEstateConfigDto {
|
|
||||||
readonly name: string;
|
|
||||||
readonly readOnlyControlIdentity?: string;
|
|
||||||
readonly inventoryAuthorityIdentity?: string;
|
|
||||||
readonly hosts: readonly CredentialHostConfigDto[];
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
|
||||||
|
|
||||||
const validRegistry = 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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('credential estate registry', (): void => {
|
|
||||||
it('requires an exact declared estate-host pair', (): void => {
|
|
||||||
const registry = parseCredentialEstateRegistry(validRegistry);
|
|
||||||
|
|
||||||
expect(registry.matches('homelab', 'git.example.invalid')).toBe(true);
|
|
||||||
expect(registry.matches('usc', 'git.example.invalid')).toBe(false);
|
|
||||||
expect(registry.matches('homelab', 'other.example.invalid')).toBe(false);
|
|
||||||
expect(registry.resolveByHost('git.example.invalid')).toMatchObject({
|
|
||||||
estate: 'homelab',
|
|
||||||
host: { host: 'git.example.invalid', provider: 'gitea' },
|
|
||||||
});
|
|
||||||
expect(registry.resolveByHost('other.example.invalid')).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a provider URL whose host differs from the declared host', (): void => {
|
|
||||||
const source = validRegistry.replace(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
'https://other.example.invalid',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(() => parseCredentialEstateRegistry(source)).toThrow(/api-host-mismatch/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects one host assigned to multiple estates', (): void => {
|
|
||||||
const source = JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
estates: [
|
|
||||||
{
|
|
||||||
name: 'homelab',
|
|
||||||
hosts: [
|
|
||||||
{
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
provider: 'gitea',
|
|
||||||
apiBaseUrl: 'https://git.example.invalid',
|
|
||||||
tokenPrefix: 'gitea-example',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'other',
|
|
||||||
hosts: [
|
|
||||||
{
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
provider: 'gitea',
|
|
||||||
apiBaseUrl: 'https://git.example.invalid',
|
|
||||||
tokenPrefix: 'gitea-other',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => parseCredentialEstateRegistry(source)).toThrow(/duplicate-host/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects URLs with userinfo, path, query, fragment, or non-HTTPS scheme', (): void => {
|
|
||||||
for (const apiBaseUrl of [
|
|
||||||
'http://git.example.invalid',
|
|
||||||
'https://[email protected]',
|
|
||||||
'https://git.example.invalid/api',
|
|
||||||
'https://git.example.invalid?x=1',
|
|
||||||
'https://git.example.invalid#x',
|
|
||||||
]) {
|
|
||||||
const source = validRegistry.replace('https://git.example.invalid', apiBaseUrl);
|
|
||||||
expect(() => parseCredentialEstateRegistry(source), apiBaseUrl).toThrow(/invalid-api-url/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires a configured read-only control for write validation', (): void => {
|
|
||||||
const registry = parseCredentialEstateRegistry(validRegistry);
|
|
||||||
const withoutControl = parseCredentialEstateRegistry(
|
|
||||||
validRegistry.replace('"readOnlyControlIdentity":"read-control",', ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(registry.readOnlyControl('homelab')).toBe('read-control');
|
|
||||||
expect(() => withoutControl.readOnlyControl('homelab')).toThrow(/read-only-control-missing/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
import type { CredentialEstateRegistry } from './credential-provider.dto.js';
|
|
||||||
import type { CredentialEstateConfigDto, CredentialHostConfigDto } from './estate-registry.dto.js';
|
|
||||||
|
|
||||||
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
||||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
||||||
const HOST = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
|
||||||
|
|
||||||
const hostSchema = z
|
|
||||||
.object({
|
|
||||||
host: z.string().regex(HOST),
|
|
||||||
provider: z.literal('gitea'),
|
|
||||||
apiBaseUrl: z.string(),
|
|
||||||
tokenPrefix: z.string().regex(NAME),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const estateSchema = z
|
|
||||||
.object({
|
|
||||||
name: z.string().regex(NAME),
|
|
||||||
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
|
|
||||||
inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(),
|
|
||||||
hosts: z.array(hostSchema).min(1),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const registrySchema = z
|
|
||||||
.object({
|
|
||||||
version: z.literal(1),
|
|
||||||
estates: z.array(estateSchema).min(1),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export class CredentialEstateRegistryError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly code: string,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(`Credential estate registry rejected: code=${code} ${message}`);
|
|
||||||
this.name = 'CredentialEstateRegistryError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateApiUrl(host: CredentialHostConfigDto): void {
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(host.apiBaseUrl);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new CredentialEstateRegistryError('invalid-api-url', detail);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
url.protocol !== 'https:' ||
|
|
||||||
url.username !== '' ||
|
|
||||||
url.password !== '' ||
|
|
||||||
url.pathname !== '/' ||
|
|
||||||
url.search !== '' ||
|
|
||||||
url.hash !== ''
|
|
||||||
) {
|
|
||||||
throw new CredentialEstateRegistryError(
|
|
||||||
'invalid-api-url',
|
|
||||||
'provider API URL must be an HTTPS origin without userinfo, path, query, or fragment',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (url.hostname !== host.host) {
|
|
||||||
throw new CredentialEstateRegistryError(
|
|
||||||
'api-host-mismatch',
|
|
||||||
'provider API URL hostname does not equal the declared host',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry {
|
|
||||||
private readonly estates: ReadonlyMap<string, CredentialEstateConfigDto>;
|
|
||||||
|
|
||||||
constructor(estates: readonly CredentialEstateConfigDto[]) {
|
|
||||||
this.estates = new Map(
|
|
||||||
estates.map(
|
|
||||||
(estate: CredentialEstateConfigDto): readonly [string, CredentialEstateConfigDto] => [
|
|
||||||
estate.name,
|
|
||||||
estate,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
matches(estate: string, host: string): boolean {
|
|
||||||
return this.resolve(estate, host) !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(estate: string, host: string): CredentialHostConfigDto | undefined {
|
|
||||||
return this.estates
|
|
||||||
.get(estate)
|
|
||||||
?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveByHost(
|
|
||||||
host: string,
|
|
||||||
): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined {
|
|
||||||
for (const [estate, config] of this.estates) {
|
|
||||||
const match = config.hosts.find(
|
|
||||||
(candidate: CredentialHostConfigDto): boolean => candidate.host === host,
|
|
||||||
);
|
|
||||||
if (match !== undefined) return { estate, host: match };
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
inventoryAuthority(estate: string): string {
|
|
||||||
const identity = this.estates.get(estate)?.inventoryAuthorityIdentity;
|
|
||||||
if (identity === undefined) {
|
|
||||||
throw new CredentialEstateRegistryError(
|
|
||||||
'inventory-authority-missing',
|
|
||||||
`estate ${estate} has no delegated inventory authority identity`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return identity;
|
|
||||||
}
|
|
||||||
|
|
||||||
readOnlyControl(estate: string): string {
|
|
||||||
const identity = this.estates.get(estate)?.readOnlyControlIdentity;
|
|
||||||
if (identity === undefined) {
|
|
||||||
throw new CredentialEstateRegistryError(
|
|
||||||
'read-only-control-missing',
|
|
||||||
`estate ${estate} has no provider-confirmed read-only control identity`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return identity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCredentialEstateRegistry(source: string): ParsedCredentialEstateRegistry {
|
|
||||||
let raw: unknown;
|
|
||||||
try {
|
|
||||||
raw = JSON.parse(source);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const detail = error instanceof Error ? error.message : String(error);
|
|
||||||
throw new CredentialEstateRegistryError('invalid-json', detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = registrySchema.safeParse(raw);
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new CredentialEstateRegistryError(
|
|
||||||
'invalid-schema',
|
|
||||||
parsed.error.issues[0]?.message ?? 'invalid',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const estateNames = new Set<string>();
|
|
||||||
const hostNames = new Set<string>();
|
|
||||||
for (const estate of parsed.data.estates) {
|
|
||||||
if (estateNames.has(estate.name)) {
|
|
||||||
throw new CredentialEstateRegistryError('duplicate-estate', estate.name);
|
|
||||||
}
|
|
||||||
estateNames.add(estate.name);
|
|
||||||
for (const host of estate.hosts) {
|
|
||||||
validateApiUrl(host);
|
|
||||||
if (hostNames.has(host.host)) {
|
|
||||||
throw new CredentialEstateRegistryError('duplicate-host', host.host);
|
|
||||||
}
|
|
||||||
hostNames.add(host.host);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ParsedCredentialEstateRegistry(parsed.data.estates);
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { chmod, copyFile, mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { mkdtemp } from 'node:fs/promises';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import { rm } from 'node:fs/promises';
|
|
||||||
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
|
||||||
import { FileCredentialResolver, FileCredentialStore } from './file-credential-store.js';
|
|
||||||
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
|
|
||||||
async function fixtureRoot(): Promise<string> {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-cred-store-'));
|
|
||||||
const root = join(cleanup, 'tokens');
|
|
||||||
await mkdir(root, { mode: 0o700 });
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
function registry(): ReturnType<typeof parseCredentialEstateRegistry> {
|
|
||||||
return parseCredentialEstateRegistry(
|
|
||||||
JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
estates: [
|
|
||||||
{
|
|
||||||
name: 'homelab',
|
|
||||||
hosts: [
|
|
||||||
{
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
provider: 'gitea',
|
|
||||||
apiBaseUrl: 'https://git.example.invalid',
|
|
||||||
tokenPrefix: 'gitea-example',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('phase-1 governed file credential resolver', (): void => {
|
|
||||||
it('resolves only the exact estate/host/identity token at a test-overridable root', async (): Promise<void> => {
|
|
||||||
const root = await fixtureRoot();
|
|
||||||
await writeFile(join(root, 'gitea-example-seat.token'), 'canary-token', { mode: 0o600 });
|
|
||||||
const resolver = new FileCredentialResolver(root, registry());
|
|
||||||
|
|
||||||
const resolved = await resolver.resolve('seat', 'homelab', 'git.example.invalid');
|
|
||||||
const wrongEstate = await resolver.resolve('seat', 'usc', 'git.example.invalid');
|
|
||||||
|
|
||||||
expect(resolved?.identity).toBe('seat');
|
|
||||||
expect(Buffer.from(resolved?.secret ?? []).toString('utf8')).toBe('canary-token');
|
|
||||||
expect(wrongEstate).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a group-writable token directory even when the token file is private', async (): Promise<void> => {
|
|
||||||
const root = await fixtureRoot();
|
|
||||||
await writeFile(join(root, 'gitea-example-seat-name.token'), 'private-token', {
|
|
||||||
mode: 0o600,
|
|
||||||
});
|
|
||||||
await chmod(root, 0o770);
|
|
||||||
const resolver = new FileCredentialResolver(root, registry());
|
|
||||||
|
|
||||||
await expect(resolver.resolve('seat-name', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
|
||||||
/insecure-token-owner/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a token file with group or other permissions', async (): Promise<void> => {
|
|
||||||
const root = await fixtureRoot();
|
|
||||||
const path = join(root, 'gitea-example-seat.token');
|
|
||||||
await writeFile(path, 'canary-token', { mode: 0o600 });
|
|
||||||
await chmod(path, 0o640);
|
|
||||||
const resolver = new FileCredentialResolver(root, registry());
|
|
||||||
|
|
||||||
await expect(resolver.resolve('seat', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
|
||||||
/insecure-token-mode/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a symlinked token instead of following it', async (): Promise<void> => {
|
|
||||||
const root = await fixtureRoot();
|
|
||||||
const target = join(cleanup ?? root, 'outside-token');
|
|
||||||
await writeFile(target, 'canary-token', { mode: 0o600 });
|
|
||||||
await symlink(target, join(root, 'gitea-example-seat.token'));
|
|
||||||
const resolver = new FileCredentialResolver(root, registry());
|
|
||||||
|
|
||||||
await expect(resolver.resolve('seat', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
|
||||||
/symbolic link|unavailable/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects traversal-shaped identities before touching storage', async (): Promise<void> => {
|
|
||||||
const root = await fixtureRoot();
|
|
||||||
const resolver = new FileCredentialResolver(root, registry());
|
|
||||||
|
|
||||||
await expect(resolver.resolve('../other', 'homelab', 'git.example.invalid')).rejects.toThrow(
|
|
||||||
/invalid-identity/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores one governed envelope, lists and reads it, then removes all credential artifacts', async (): Promise<void> => {
|
|
||||||
const root = await fixtureRoot();
|
|
||||||
const store = new FileCredentialStore(root, registry());
|
|
||||||
await store.put(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
providerLogin: 'seat',
|
|
||||||
tokenName: 'mosaic-seat-1',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
createdAt: '2026-08-05T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
new TextEncoder().encode('new-private-token'),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
|
|
||||||
await expect(
|
|
||||||
store.readBinding('seat', 'homelab', 'git.example.invalid'),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
providerLogin: 'seat',
|
|
||||||
tokenName: 'mosaic-seat-1',
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
new FileCredentialResolver(root, registry()).resolve(
|
|
||||||
'seat',
|
|
||||||
'homelab',
|
|
||||||
'git.example.invalid',
|
|
||||||
),
|
|
||||||
).resolves.toMatchObject({ identity: 'seat' });
|
|
||||||
await copyFile(
|
|
||||||
join(root, 'gitea-example-seat.credential.json'),
|
|
||||||
join(root, 'gitea-example-other.credential.json'),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
new FileCredentialResolver(root, registry()).resolve(
|
|
||||||
'other',
|
|
||||||
'homelab',
|
|
||||||
'git.example.invalid',
|
|
||||||
),
|
|
||||||
).rejects.toThrow(/credential-binding-mismatch/);
|
|
||||||
await unlink(join(root, 'gitea-example-other.credential.json'));
|
|
||||||
await store.remove('seat', 'homelab', 'git.example.invalid');
|
|
||||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('changes generation for metadata-only rebinding and rejects stale replacement or removal', async (): Promise<void> => {
|
|
||||||
const root = await fixtureRoot();
|
|
||||||
const store = new FileCredentialStore(root, registry());
|
|
||||||
const secret = new TextEncoder().encode('same-private-token');
|
|
||||||
const originalBinding = {
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
providerLogin: 'seat',
|
|
||||||
tokenName: 'mosaic-seat-original',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
createdAt: '2026-08-05T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
await store.put(originalBinding, secret);
|
|
||||||
const original = await store.snapshot('seat', 'homelab', 'git.example.invalid');
|
|
||||||
if (original === undefined) throw new Error('original generation was not stored');
|
|
||||||
try {
|
|
||||||
await store.put(
|
|
||||||
{
|
|
||||||
...originalBinding,
|
|
||||||
tokenName: 'mosaic-seat-rebound',
|
|
||||||
createdAt: '2026-08-05T00:00:01.000Z',
|
|
||||||
},
|
|
||||||
secret,
|
|
||||||
original.generation,
|
|
||||||
);
|
|
||||||
const rebound = await store.snapshot('seat', 'homelab', 'git.example.invalid');
|
|
||||||
try {
|
|
||||||
expect(rebound?.generation).not.toBe(original.generation);
|
|
||||||
await expect(
|
|
||||||
store.put(
|
|
||||||
{ ...originalBinding, tokenName: 'mosaic-seat-stale' },
|
|
||||||
secret,
|
|
||||||
original.generation,
|
|
||||||
),
|
|
||||||
).rejects.toThrow(/credential-generation-mismatch/);
|
|
||||||
await expect(
|
|
||||||
store.remove('seat', 'homelab', 'git.example.invalid', original.generation),
|
|
||||||
).rejects.toThrow(/credential-generation-mismatch/);
|
|
||||||
await expect(
|
|
||||||
store.readBinding('seat', 'homelab', 'git.example.invalid'),
|
|
||||||
).resolves.toMatchObject({ tokenName: 'mosaic-seat-rebound' });
|
|
||||||
} finally {
|
|
||||||
rebound?.secret.fill(0);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
original.secret.fill(0);
|
|
||||||
secret.fill(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined for an absent token without borrowing another identity', async (): Promise<void> => {
|
|
||||||
const root = await fixtureRoot();
|
|
||||||
await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 });
|
|
||||||
const resolver = new FileCredentialResolver(root, registry());
|
|
||||||
|
|
||||||
const resolved = await resolver.resolve('missing-seat', 'homelab', 'git.example.invalid');
|
|
||||||
|
|
||||||
expect(resolved).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
|
||||||
import { lstatSync } from 'node:fs';
|
|
||||||
import { open, readdir, rename, unlink } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import {
|
|
||||||
ensureManagedDirectory,
|
|
||||||
readRegularFileSecure,
|
|
||||||
type SecureFileSnapshot,
|
|
||||||
} from '../fleet/secure-file.js';
|
|
||||||
import type { CredentialBindingMetadataDto } from './credential-binding.dto.js';
|
|
||||||
import type { CredentialResolver, ResolvedCredential } from './credential-provider.dto.js';
|
|
||||||
import type { ParsedCredentialEstateRegistry } from './estate-registry.js';
|
|
||||||
|
|
||||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
||||||
const MAX_TOKEN_BYTES = 16 * 1024;
|
|
||||||
const bindingSchema = z
|
|
||||||
.object({
|
|
||||||
schemaVersion: z.literal(1),
|
|
||||||
identity: z.string().regex(IDENTITY),
|
|
||||||
estate: z.string().min(1),
|
|
||||||
host: z.string().min(1),
|
|
||||||
providerLogin: z.string().regex(IDENTITY),
|
|
||||||
tokenName: z.string().regex(IDENTITY),
|
|
||||||
scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32),
|
|
||||||
createdAt: z.string().datetime(),
|
|
||||||
tokenDigest: z
|
|
||||||
.string()
|
|
||||||
.regex(/^[a-f0-9]{64}$/)
|
|
||||||
.optional(),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
const credentialEnvelopeSchema = bindingSchema.extend({
|
|
||||||
token: z.string().min(1).max(MAX_TOKEN_BYTES).regex(/^\S+$/),
|
|
||||||
});
|
|
||||||
|
|
||||||
export class CredentialStoreError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly code: string,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(`Credential store rejected: code=${code} ${message}`);
|
|
||||||
this.name = 'CredentialStoreError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMissingFile(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
error instanceof Error &&
|
|
||||||
'code' in error &&
|
|
||||||
typeof error.code === 'string' &&
|
|
||||||
error.code === 'ENOENT'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function credentialBindingGeneration(
|
|
||||||
metadata: CredentialBindingMetadataDto,
|
|
||||||
secret: Uint8Array,
|
|
||||||
): string {
|
|
||||||
const tokenDigest = createHash('sha256').update(secret).digest('hex');
|
|
||||||
const canonicalState = JSON.stringify({
|
|
||||||
schemaVersion: 1,
|
|
||||||
identity: metadata.identity,
|
|
||||||
estate: metadata.estate,
|
|
||||||
host: metadata.host,
|
|
||||||
providerLogin: metadata.providerLogin,
|
|
||||||
tokenName: metadata.tokenName,
|
|
||||||
scopes: [...metadata.scopes].sort(),
|
|
||||||
createdAt: metadata.createdAt,
|
|
||||||
tokenDigest,
|
|
||||||
});
|
|
||||||
return createHash('sha256').update(canonicalState).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateSecret(content: Buffer): Uint8Array {
|
|
||||||
if (content.byteLength === 0 || content.byteLength > MAX_TOKEN_BYTES) {
|
|
||||||
throw new CredentialStoreError('invalid-token-size', 'token file size is outside bounds');
|
|
||||||
}
|
|
||||||
for (const byte of content) {
|
|
||||||
if (byte <= 0x20 || byte === 0x7f) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'invalid-token-bytes',
|
|
||||||
'token file contains whitespace or control bytes',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Uint8Array(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileCredentialResolver implements CredentialResolver {
|
|
||||||
constructor(
|
|
||||||
private readonly tokenDirectory: string,
|
|
||||||
private readonly estateRegistry: ParsedCredentialEstateRegistry,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async resolve(
|
|
||||||
identity: string,
|
|
||||||
estate: string,
|
|
||||||
host: string,
|
|
||||||
): Promise<ResolvedCredential | undefined> {
|
|
||||||
if (!IDENTITY.test(identity)) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'invalid-identity',
|
|
||||||
'identity is outside the allowlist grammar',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const hostConfig = this.estateRegistry.resolve(estate, host);
|
|
||||||
if (hostConfig === undefined) return undefined;
|
|
||||||
|
|
||||||
const currentUid = process.getuid?.();
|
|
||||||
if (currentUid === undefined) {
|
|
||||||
throw new CredentialStoreError('insecure-token-owner', 'runtime uid is unavailable');
|
|
||||||
}
|
|
||||||
const directory = lstatSync(this.tokenDirectory);
|
|
||||||
if (
|
|
||||||
!directory.isDirectory() ||
|
|
||||||
directory.isSymbolicLink() ||
|
|
||||||
directory.uid !== currentUid ||
|
|
||||||
(directory.mode & 0o022) !== 0
|
|
||||||
) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'insecure-token-owner',
|
|
||||||
'token directory ownership or write permissions are unsafe',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const envelopePath = join(
|
|
||||||
this.tokenDirectory,
|
|
||||||
`${hostConfig.tokenPrefix}-${identity}.credential.json`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const envelopeSnapshot = readRegularFileSecure(envelopePath, {
|
|
||||||
root: this.tokenDirectory,
|
|
||||||
maxBytes: 64 * 1024,
|
|
||||||
});
|
|
||||||
const envelope = credentialEnvelopeSchema.safeParse(
|
|
||||||
JSON.parse(envelopeSnapshot.content.toString('utf8')),
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!envelope.success ||
|
|
||||||
envelopeSnapshot.uid !== process.getuid?.() ||
|
|
||||||
(envelopeSnapshot.mode & 0o077) !== 0
|
|
||||||
) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'invalid-binding',
|
|
||||||
'credential envelope failed schema, owner, or mode validation',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
envelope.data.identity !== identity ||
|
|
||||||
envelope.data.estate !== estate ||
|
|
||||||
envelope.data.host !== host ||
|
|
||||||
envelope.data.providerLogin !== identity
|
|
||||||
) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'credential-binding-mismatch',
|
|
||||||
'credential envelope does not match the requested identity, estate, host, and principal',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const secret = validateSecret(Buffer.from(envelope.data.token, 'utf8'));
|
|
||||||
const digest = createHash('sha256').update(secret).digest('hex');
|
|
||||||
if (envelope.data.tokenDigest !== digest) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'credential-generation-mismatch',
|
|
||||||
'credential envelope digest does not match its token',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Object.freeze({
|
|
||||||
identity,
|
|
||||||
estate,
|
|
||||||
host,
|
|
||||||
resolutionId: randomUUID(),
|
|
||||||
secret,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!isMissingFile(error)) throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`);
|
|
||||||
let snapshot: SecureFileSnapshot;
|
|
||||||
try {
|
|
||||||
snapshot = readRegularFileSecure(path, {
|
|
||||||
root: this.tokenDirectory,
|
|
||||||
maxBytes: MAX_TOKEN_BYTES,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (isMissingFile(error)) return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bindingPath = join(
|
|
||||||
this.tokenDirectory,
|
|
||||||
`${hostConfig.tokenPrefix}-${identity}.binding.json`,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const bindingSnapshot = readRegularFileSecure(bindingPath, {
|
|
||||||
root: this.tokenDirectory,
|
|
||||||
maxBytes: 64 * 1024,
|
|
||||||
});
|
|
||||||
const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8')));
|
|
||||||
const digest = createHash('sha256').update(snapshot.content).digest('hex');
|
|
||||||
if (
|
|
||||||
!binding.success ||
|
|
||||||
binding.data.tokenDigest !== digest ||
|
|
||||||
binding.data.identity !== identity ||
|
|
||||||
binding.data.estate !== estate ||
|
|
||||||
binding.data.host !== host ||
|
|
||||||
binding.data.providerLogin !== identity
|
|
||||||
) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'credential-generation-mismatch',
|
|
||||||
'token and binding metadata are not one committed identity-bound generation',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!isMissingFile(error)) throw error;
|
|
||||||
// Legacy token files predate binding metadata and remain readable until rotated.
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = snapshot.mode & 0o777;
|
|
||||||
if (snapshot.uid !== currentUid) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'insecure-token-owner',
|
|
||||||
'token file is not owned by the runtime uid',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if ((permissions & 0o077) !== 0) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'insecure-token-mode',
|
|
||||||
'token file grants group or other access',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({
|
|
||||||
identity,
|
|
||||||
estate,
|
|
||||||
host,
|
|
||||||
resolutionId: randomUUID(),
|
|
||||||
secret: validateSecret(snapshot.content),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPrivateTokenDirectory(path: string): {
|
|
||||||
readonly dev: number | bigint;
|
|
||||||
readonly ino: number | bigint;
|
|
||||||
} {
|
|
||||||
const stat = lstatSync(path);
|
|
||||||
if (
|
|
||||||
!stat.isDirectory() ||
|
|
||||||
stat.isSymbolicLink() ||
|
|
||||||
stat.uid !== process.getuid?.() ||
|
|
||||||
(stat.mode & 0o022) !== 0
|
|
||||||
) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'insecure-token-owner',
|
|
||||||
'token directory ownership or write permissions are unsafe',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { dev: stat.dev, ino: stat.ino };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncDirectory(path: string): Promise<void> {
|
|
||||||
const handle = await open(path, 'r');
|
|
||||||
try {
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileCredentialStore {
|
|
||||||
constructor(
|
|
||||||
private readonly tokenDirectory: string,
|
|
||||||
private readonly estateRegistry: ParsedCredentialEstateRegistry,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private paths(
|
|
||||||
identity: string,
|
|
||||||
estate: string,
|
|
||||||
host: string,
|
|
||||||
): {
|
|
||||||
readonly token: string;
|
|
||||||
readonly binding: string;
|
|
||||||
readonly envelope: string;
|
|
||||||
readonly prefix: string;
|
|
||||||
} {
|
|
||||||
if (!IDENTITY.test(identity)) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'invalid-identity',
|
|
||||||
'identity is outside the allowlist grammar',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const config = this.estateRegistry.resolve(estate, host);
|
|
||||||
if (config === undefined) {
|
|
||||||
throw new CredentialStoreError('estate-host-mismatch', 'estate and host do not match');
|
|
||||||
}
|
|
||||||
const prefix = `${config.tokenPrefix}-${identity}`;
|
|
||||||
return {
|
|
||||||
token: join(this.tokenDirectory, `${prefix}.token`),
|
|
||||||
binding: join(this.tokenDirectory, `${prefix}.binding.json`),
|
|
||||||
envelope: join(this.tokenDirectory, `${prefix}.credential.json`),
|
|
||||||
prefix,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(
|
|
||||||
metadata: CredentialBindingMetadataDto,
|
|
||||||
secret: Uint8Array,
|
|
||||||
expectedGeneration?: string | null,
|
|
||||||
): Promise<void> {
|
|
||||||
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
|
|
||||||
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
|
|
||||||
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
|
|
||||||
const token = validateSecret(Buffer.from(secret));
|
|
||||||
const envelope = credentialEnvelopeSchema.parse({
|
|
||||||
...metadata,
|
|
||||||
schemaVersion: 1,
|
|
||||||
tokenDigest: createHash('sha256').update(token).digest('hex'),
|
|
||||||
token: Buffer.from(token).toString('utf8'),
|
|
||||||
});
|
|
||||||
const suffix = randomUUID();
|
|
||||||
const envelopeTemp = `${paths.envelope}.${suffix}.tmp`;
|
|
||||||
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
|
||||||
let lock;
|
|
||||||
try {
|
|
||||||
lock = await open(lockPath, 'wx', 0o600);
|
|
||||||
} catch {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'conflicting-credential-mutation',
|
|
||||||
'another mutation owns the identity lock',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (expectedGeneration !== undefined) {
|
|
||||||
const current = await this.snapshot(metadata.identity, metadata.estate, metadata.host);
|
|
||||||
try {
|
|
||||||
const actualGeneration = current?.generation ?? null;
|
|
||||||
if (actualGeneration !== expectedGeneration) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'credential-generation-mismatch',
|
|
||||||
'credential generation changed before replacement',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
current?.secret.fill(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const handle = await open(envelopeTemp, 'wx', 0o600);
|
|
||||||
try {
|
|
||||||
await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8');
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
const beforeCommit = assertPrivateTokenDirectory(this.tokenDirectory);
|
|
||||||
if (
|
|
||||||
beforeCommit.dev !== directoryIdentity.dev ||
|
|
||||||
beforeCommit.ino !== directoryIdentity.ino
|
|
||||||
) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'insecure-token-owner',
|
|
||||||
'token directory changed during credential commit',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await rename(envelopeTemp, paths.envelope);
|
|
||||||
await syncDirectory(this.tokenDirectory);
|
|
||||||
} finally {
|
|
||||||
await lock.close();
|
|
||||||
await unlink(envelopeTemp).catch((): void => undefined);
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
await syncDirectory(this.tokenDirectory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async snapshot(
|
|
||||||
identity: string,
|
|
||||||
estate: string,
|
|
||||||
host: string,
|
|
||||||
): Promise<
|
|
||||||
| {
|
|
||||||
readonly binding: CredentialBindingMetadataDto;
|
|
||||||
readonly secret: Uint8Array;
|
|
||||||
readonly generation: string;
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
> {
|
|
||||||
const binding = await this.readBinding(identity, estate, host);
|
|
||||||
if (binding === undefined) return undefined;
|
|
||||||
const resolved = await new FileCredentialResolver(
|
|
||||||
this.tokenDirectory,
|
|
||||||
this.estateRegistry,
|
|
||||||
).resolve(identity, estate, host);
|
|
||||||
if (resolved === undefined) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'invalid-binding',
|
|
||||||
'binding metadata exists without its token generation',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const secret = new Uint8Array(resolved.secret);
|
|
||||||
const generation = credentialBindingGeneration(binding, resolved.secret);
|
|
||||||
resolved.secret.fill(0);
|
|
||||||
return { binding, secret, generation };
|
|
||||||
}
|
|
||||||
|
|
||||||
async readBinding(
|
|
||||||
identity: string,
|
|
||||||
estate: string,
|
|
||||||
host: string,
|
|
||||||
): Promise<CredentialBindingMetadataDto | undefined> {
|
|
||||||
const paths = this.paths(identity, estate, host);
|
|
||||||
let snapshot: SecureFileSnapshot;
|
|
||||||
try {
|
|
||||||
const envelope = readRegularFileSecure(paths.envelope, {
|
|
||||||
root: this.tokenDirectory,
|
|
||||||
maxBytes: 64 * 1024,
|
|
||||||
});
|
|
||||||
const parsed = credentialEnvelopeSchema.safeParse(
|
|
||||||
JSON.parse(envelope.content.toString('utf8')),
|
|
||||||
);
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed');
|
|
||||||
}
|
|
||||||
const verified = await new FileCredentialResolver(
|
|
||||||
this.tokenDirectory,
|
|
||||||
this.estateRegistry,
|
|
||||||
).resolve(identity, estate, host);
|
|
||||||
if (verified === undefined) {
|
|
||||||
throw new CredentialStoreError('invalid-binding', 'credential envelope was not resolvable');
|
|
||||||
}
|
|
||||||
verified.secret.fill(0);
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
identity: parsed.data.identity,
|
|
||||||
estate: parsed.data.estate,
|
|
||||||
host: parsed.data.host,
|
|
||||||
providerLogin: parsed.data.providerLogin,
|
|
||||||
tokenName: parsed.data.tokenName,
|
|
||||||
scopes: parsed.data.scopes,
|
|
||||||
createdAt: parsed.data.createdAt,
|
|
||||||
tokenDigest: parsed.data.tokenDigest,
|
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!isMissingFile(error)) throw error;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
snapshot = readRegularFileSecure(paths.binding, {
|
|
||||||
root: this.tokenDirectory,
|
|
||||||
maxBytes: 64 * 1024,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (isMissingFile(error)) return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
|
||||||
throw new CredentialStoreError('insecure-token-owner', 'binding metadata is not private');
|
|
||||||
}
|
|
||||||
const parsed = bindingSchema.safeParse(JSON.parse(snapshot.content.toString('utf8')));
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'invalid-binding',
|
|
||||||
'binding metadata failed schema validation',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parsed.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async list(estate: string, host: string): Promise<readonly string[]> {
|
|
||||||
const config = this.estateRegistry.resolve(estate, host);
|
|
||||||
if (config === undefined) return [];
|
|
||||||
const names = await readdir(this.tokenDirectory);
|
|
||||||
const prefix = `${config.tokenPrefix}-`;
|
|
||||||
return [
|
|
||||||
...new Set(
|
|
||||||
names.flatMap((name): string[] => {
|
|
||||||
if (!name.startsWith(prefix)) return [];
|
|
||||||
if (name.endsWith('.token')) {
|
|
||||||
return [name.slice(prefix.length, -'.token'.length)];
|
|
||||||
}
|
|
||||||
if (name.endsWith('.credential.json')) {
|
|
||||||
return [name.slice(prefix.length, -'.credential.json'.length)];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
.filter((identity): boolean => IDENTITY.test(identity))
|
|
||||||
.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(
|
|
||||||
identity: string,
|
|
||||||
estate: string,
|
|
||||||
host: string,
|
|
||||||
expectedTokenDigest?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const paths = this.paths(identity, estate, host);
|
|
||||||
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
|
|
||||||
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
|
|
||||||
let lock;
|
|
||||||
try {
|
|
||||||
lock = await open(lockPath, 'wx', 0o600);
|
|
||||||
} catch {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'conflicting-credential-mutation',
|
|
||||||
'another mutation owns the identity lock',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (expectedTokenDigest !== undefined) {
|
|
||||||
const current = await this.snapshot(identity, estate, host);
|
|
||||||
try {
|
|
||||||
if (current === undefined || current.generation !== expectedTokenDigest) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'credential-generation-mismatch',
|
|
||||||
'credential generation changed before removal',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
current?.secret.fill(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const beforeRemoval = assertPrivateTokenDirectory(this.tokenDirectory);
|
|
||||||
if (
|
|
||||||
beforeRemoval.dev !== directoryIdentity.dev ||
|
|
||||||
beforeRemoval.ino !== directoryIdentity.ino
|
|
||||||
) {
|
|
||||||
throw new CredentialStoreError(
|
|
||||||
'insecure-token-owner',
|
|
||||||
'token directory changed during credential removal',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await unlink(paths.token).catch((error: unknown): void => {
|
|
||||||
if (!isMissingFile(error)) throw error;
|
|
||||||
});
|
|
||||||
await unlink(paths.binding).catch((error: unknown): void => {
|
|
||||||
if (!isMissingFile(error)) throw error;
|
|
||||||
});
|
|
||||||
await unlink(paths.envelope).catch((error: unknown): void => {
|
|
||||||
if (!isMissingFile(error)) throw error;
|
|
||||||
});
|
|
||||||
await syncDirectory(this.tokenDirectory);
|
|
||||||
} finally {
|
|
||||||
await lock.close();
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
await syncDirectory(this.tokenDirectory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { GiteaCredentialProviderAdapter, GiteaTeamGrantProviderAdapter } from './gitea-provider.js';
|
|
||||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
||||||
|
|
||||||
const credential: ResolvedCredential = Object.freeze({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
resolutionId: 'resolution-1',
|
|
||||||
secret: new TextEncoder().encode('seeded-secret-canary'),
|
|
||||||
});
|
|
||||||
|
|
||||||
function jsonResponse(body: object, status = 200): Response {
|
|
||||||
return new Response(JSON.stringify(body), {
|
|
||||||
status,
|
|
||||||
headers: { 'content-type': 'application/json;charset=utf-8' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Gitea credential provider transport', (): void => {
|
|
||||||
it('reads the provider identity with the fixed transport and no secret in the URL', async (): Promise<void> => {
|
|
||||||
const calls: Array<{ readonly input: string; readonly init?: RequestInit }> = [];
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
||||||
calls.push({ input: String(input), ...(init === undefined ? {} : { init }) });
|
|
||||||
return jsonResponse({ id: 21, login: 'seat-name' });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const evidence = await adapter.readIdentity(credential);
|
|
||||||
|
|
||||||
expect(evidence).toEqual({
|
|
||||||
login: 'seat-name',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json;charset=utf-8',
|
|
||||||
});
|
|
||||||
expect(calls[0]?.input).toBe('https://git.example.invalid/api/v1/user');
|
|
||||||
expect(calls[0]?.input).not.toContain('seeded-secret-canary');
|
|
||||||
expect(new Headers(calls[0]?.init?.headers).get('user-agent')).toBe('mosaic-cred/1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps the authenticated provider repository object to effective permission', async (): Promise<void> => {
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (): Promise<Response> =>
|
|
||||||
jsonResponse({
|
|
||||||
id: 99,
|
|
||||||
full_name: 'owner/repo',
|
|
||||||
permissions: { admin: false, push: true, pull: true },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const evidence = await adapter.readRepositoryPermission(credential, 'owner/repo');
|
|
||||||
|
|
||||||
expect(evidence.effective).toBe('write');
|
|
||||||
expect(evidence.endpoint).toBe('GET /api/v1/repos/owner/repo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('binds an authenticated receive-pack advertisement to the supplied credential handle', async (): Promise<void> => {
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (): Promise<Response> =>
|
|
||||||
new Response('001f# service=git-receive-pack\n0000', {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/x-git-receive-pack-advertisement',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const evidence = await adapter.probeReceivePack(credential, 'owner/repo');
|
|
||||||
|
|
||||||
expect(evidence).toEqual({
|
|
||||||
state: 'advertised',
|
|
||||||
principal: 'seat-name',
|
|
||||||
resolutionId: 'resolution-1',
|
|
||||||
contentType: 'application/x-git-receive-pack-advertisement',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports authenticated and unauthenticated receive-pack refusals without inventing success', async (): Promise<void> => {
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (): Promise<Response> =>
|
|
||||||
new Response('denied', { status: 403, headers: { 'content-type': 'text/plain' } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(adapter.probeReceivePack(credential, 'owner/repo')).resolves.toMatchObject({
|
|
||||||
state: 'refused',
|
|
||||||
principal: 'seat-name',
|
|
||||||
resolutionId: 'resolution-1',
|
|
||||||
});
|
|
||||||
await expect(adapter.probeReceivePack(undefined, 'owner/repo')).resolves.toMatchObject({
|
|
||||||
state: 'refused',
|
|
||||||
principal: null,
|
|
||||||
resolutionId: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not call a scope-forbidden identity read a dead credential', async (): Promise<void> => {
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (): Promise<Response> => jsonResponse({ message: 'forbidden' }, 403),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
|
||||||
code: 'identity-read-forbidden',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies only the supplied credential as rejected without inferring identity absence', async (): Promise<void> => {
|
|
||||||
let calls = 0;
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (): Promise<Response> => {
|
|
||||||
calls += 1;
|
|
||||||
return jsonResponse({ message: 'unauthorized' }, 401);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
|
||||||
code: 'credential-rejected',
|
|
||||||
});
|
|
||||||
expect(calls).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies a rejected credential separately when the declared identity exists', async (): Promise<void> => {
|
|
||||||
let call = 0;
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (): Promise<Response> => {
|
|
||||||
call += 1;
|
|
||||||
if (call === 1) return jsonResponse({ message: 'unauthorized' }, 401);
|
|
||||||
return jsonResponse({ id: 21, login: 'seat-name' });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
|
||||||
code: 'credential-rejected',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cancels an undeclared oversized streaming provider response before buffering it all', async (): Promise<void> => {
|
|
||||||
let pulls = 0;
|
|
||||||
let cancelled = false;
|
|
||||||
const stream = new ReadableStream<Uint8Array>({
|
|
||||||
pull(controller): void {
|
|
||||||
pulls += 1;
|
|
||||||
controller.enqueue(new Uint8Array(64 * 1024));
|
|
||||||
if (pulls === 100) controller.close();
|
|
||||||
},
|
|
||||||
cancel(): void {
|
|
||||||
cancelled = true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (): Promise<Response> =>
|
|
||||||
new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
|
||||||
code: 'unexpected-provider-shape',
|
|
||||||
});
|
|
||||||
expect(pulls).toBeLessThan(100);
|
|
||||||
expect(cancelled).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a 200 HTML identity response as unexpected content type', async (): Promise<void> => {
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (): Promise<Response> =>
|
|
||||||
new Response('<html>not an API object</html>', {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'text/html' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
|
||||||
code: 'unexpected-content-type',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reads team permission, member attachment, and repository attachment separately', async (): Promise<void> => {
|
|
||||||
let memberRemoved = false;
|
|
||||||
let repositoryDetached = false;
|
|
||||||
const adapter = new GiteaTeamGrantProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
||||||
const url = String(input);
|
|
||||||
if (url.endsWith('/api/v1/orgs/owner/teams')) {
|
|
||||||
return jsonResponse([{ id: 7, name: 'writers', permission: 'write' }]);
|
|
||||||
}
|
|
||||||
if (init?.method === 'PUT') return new Response(null, { status: 204 });
|
|
||||||
if (init?.method === 'DELETE') {
|
|
||||||
if (url.includes('/members/')) memberRemoved = true;
|
|
||||||
if (url.includes('/repos/')) repositoryDetached = true;
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
}
|
|
||||||
if (url.includes('/members/seat-name')) {
|
|
||||||
return jsonResponse({ id: 21, login: 'seat-name' });
|
|
||||||
}
|
|
||||||
if (url.includes('/repos/owner/repo')) {
|
|
||||||
return jsonResponse({
|
|
||||||
id: 4,
|
|
||||||
full_name: 'owner/repo',
|
|
||||||
permissions: { admin: false, push: true, pull: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return jsonResponse({ message: 'unexpected' }, 500);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const team = await adapter.resolveTeam(credential, 'owner', 'writers');
|
|
||||||
await adapter.addTeamMember(credential, team.id, 'seat-name');
|
|
||||||
await adapter.attachTeamRepository(credential, team.id, 'owner/repo');
|
|
||||||
await expect(adapter.readTeamMember(credential, team.id, 'seat-name')).resolves.toMatchObject({
|
|
||||||
state: 'present',
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
adapter.readTeamRepository(credential, team.id, 'owner/repo'),
|
|
||||||
).resolves.toMatchObject({ state: 'present' });
|
|
||||||
await adapter.removeTeamMember(credential, team.id, 'seat-name');
|
|
||||||
await adapter.detachTeamRepository(credential, team.id, 'owner/repo');
|
|
||||||
expect(memberRemoved).toBe(true);
|
|
||||||
expect(repositoryDetached).toBe(true);
|
|
||||||
expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a successful team read-back that names the wrong object', async (): Promise<void> => {
|
|
||||||
const adapter = new GiteaTeamGrantProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (): Promise<Response> => jsonResponse({ id: 99, login: 'other-seat' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(adapter.readTeamMember(credential, 7, 'seat-name')).rejects.toMatchObject({
|
|
||||||
code: 'unexpected-provider-shape',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bounds a provider that never returns response headers', async (): Promise<void> => {
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (_input: string | URL | Request, init?: RequestInit): Promise<Response> =>
|
|
||||||
new Promise<Response>((_resolve, reject): void => {
|
|
||||||
init?.signal?.addEventListener('abort', (): void => {
|
|
||||||
reject(new Error('aborted'));
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
|
||||||
code: 'provider-unavailable',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never includes seeded secret material in provider error messages', async (): Promise<void> => {
|
|
||||||
const adapter = new GiteaCredentialProviderAdapter(
|
|
||||||
'https://git.example.invalid',
|
|
||||||
async (): Promise<Response> => {
|
|
||||||
throw new Error('connection reset');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
let message = '';
|
|
||||||
try {
|
|
||||||
await adapter.readIdentity(credential);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
message = error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
expect(message).not.toContain('seeded-secret-canary');
|
|
||||||
expect(message).toContain('provider-unavailable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,943 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js';
|
|
||||||
import type { GiteaGrantProvider } from './grant.js';
|
|
||||||
import type { GiteaLifecycleProvider, MintedToken } from './lifecycle.js';
|
|
||||||
import type { TokenObjectEvidenceDto } from './lifecycle.dto.js';
|
|
||||||
import type {
|
|
||||||
GiteaTeamGrantProvider,
|
|
||||||
PresenceEvidence,
|
|
||||||
TeamRepositorySetEvidence,
|
|
||||||
TeamResolutionEvidence,
|
|
||||||
} from './team-grant.js';
|
|
||||||
import type {
|
|
||||||
CollaboratorPermissionEvidenceDto,
|
|
||||||
OrganizationMembershipEvidenceDto,
|
|
||||||
} from './grant.dto.js';
|
|
||||||
import type {
|
|
||||||
ProviderIdentityEvidenceDto,
|
|
||||||
ReceivePackEvidenceDto,
|
|
||||||
RepositoryPermission,
|
|
||||||
RepositoryPermissionEvidenceDto,
|
|
||||||
} from './credential-result.dto.js';
|
|
||||||
|
|
||||||
const MAX_PROVIDER_BYTES = 1024 * 1024;
|
|
||||||
const USER_AGENT = 'mosaic-cred/1';
|
|
||||||
const JSON_CONTENT_TYPE = 'application/json';
|
|
||||||
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
|
|
||||||
const REPO_COMPONENT = /^[A-Za-z0-9_.-]+$/;
|
|
||||||
|
|
||||||
type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
||||||
|
|
||||||
const userSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.number().int(),
|
|
||||||
login: z.string().min(1),
|
|
||||||
is_admin: z.boolean().optional(),
|
|
||||||
visibility: z.enum(['public', 'limited', 'private']).optional(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
const collaboratorPermissionSchema = z
|
|
||||||
.object({
|
|
||||||
permission: z.enum(['read', 'write', 'admin']),
|
|
||||||
user: z.object({ login: z.string().min(1) }).passthrough(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
const organizationSchema = z.object({ username: z.string().min(1) }).passthrough();
|
|
||||||
const tokenObjectSchema = z
|
|
||||||
.object({
|
|
||||||
name: z.string().min(1),
|
|
||||||
sha1: z.string().min(1).optional(),
|
|
||||||
token: z.string().min(1).optional(),
|
|
||||||
scopes: z.array(z.string()).default([]),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
const teamSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.number().int().positive(),
|
|
||||||
name: z.string().min(1),
|
|
||||||
permission: z.enum(['read', 'write', 'admin']),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
const repoSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.number().int(),
|
|
||||||
full_name: z.string().min(3),
|
|
||||||
permissions: z
|
|
||||||
.object({
|
|
||||||
admin: z.boolean(),
|
|
||||||
push: z.boolean(),
|
|
||||||
pull: z.boolean(),
|
|
||||||
})
|
|
||||||
.strict(),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
|
|
||||||
export class CredentialProviderEvidenceError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly code: string,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(`Gitea credential evidence unavailable: code=${code} ${message}`);
|
|
||||||
this.name = 'CredentialProviderEvidenceError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function contentType(response: Response): string {
|
|
||||||
return response.headers.get('content-type') ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isJson(response: Response): boolean {
|
|
||||||
return contentType(response).toLowerCase().startsWith(JSON_CONTENT_TYPE);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function boundedBody(response: Response): Promise<Uint8Array> {
|
|
||||||
const declared = response.headers.get('content-length');
|
|
||||||
if (declared !== null) {
|
|
||||||
if (!/^\d+$/.test(declared)) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'provider response declared an invalid content length',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const bytes = Number(declared);
|
|
||||||
if (!Number.isSafeInteger(bytes) || bytes > MAX_PROVIDER_BYTES) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'provider response exceeded the bounded size',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (response.body === null) return new Uint8Array();
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
let total = 0;
|
|
||||||
try {
|
|
||||||
for (;;) {
|
|
||||||
const next = await reader.read();
|
|
||||||
if (next.done) break;
|
|
||||||
total += next.value.byteLength;
|
|
||||||
if (total > MAX_PROVIDER_BYTES) {
|
|
||||||
await reader.cancel();
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'provider response exceeded the bounded size',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
chunks.push(next.value);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
if (declared !== null && total !== Number(declared)) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'provider response length contradicted its declaration',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const body = new Uint8Array(total);
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
body.set(chunk, offset);
|
|
||||||
offset += chunk.byteLength;
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function jsonObject(response: Response): Promise<unknown> {
|
|
||||||
if (!isJson(response)) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-content-type',
|
|
||||||
'provider response was not JSON',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const bytes = await boundedBody(response);
|
|
||||||
try {
|
|
||||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
||||||
} catch {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'provider JSON could not be parsed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokenText(resolved: ResolvedCredential): string {
|
|
||||||
try {
|
|
||||||
return new TextDecoder('utf-8', { fatal: true }).decode(resolved.secret);
|
|
||||||
} catch {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'credential bytes were not valid text',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiAuthorization(resolved: ResolvedCredential): string {
|
|
||||||
return `token ${tokenText(resolved)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function gitAuthorization(resolved: ResolvedCredential): string {
|
|
||||||
const basic = Buffer.from(`${resolved.identity}:${tokenText(resolved)}`, 'utf8').toString(
|
|
||||||
'base64',
|
|
||||||
);
|
|
||||||
return `Basic ${basic}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function repoPath(repo: string): { readonly owner: string; readonly name: string } {
|
|
||||||
const pieces = repo.split('/');
|
|
||||||
const owner = pieces[0];
|
|
||||||
const name = pieces[1];
|
|
||||||
if (
|
|
||||||
pieces.length !== 2 ||
|
|
||||||
owner === undefined ||
|
|
||||||
name === undefined ||
|
|
||||||
!REPO_COMPONENT.test(owner) ||
|
|
||||||
!REPO_COMPONENT.test(name)
|
|
||||||
) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'repository must be exactly owner/name in the allowlist grammar',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { owner, name };
|
|
||||||
}
|
|
||||||
|
|
||||||
function effectivePermission(permissions: {
|
|
||||||
readonly admin: boolean;
|
|
||||||
readonly push: boolean;
|
|
||||||
readonly pull: boolean;
|
|
||||||
}): RepositoryPermission {
|
|
||||||
if (permissions.admin) return 'admin';
|
|
||||||
if (permissions.push) return 'write';
|
|
||||||
if (permissions.pull) return 'read';
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider {
|
|
||||||
protected readonly origin: string;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
apiBaseUrl: string,
|
|
||||||
private readonly fetchImpl: FetchLike = fetch,
|
|
||||||
private readonly requestTimeoutMs = 10_000,
|
|
||||||
) {
|
|
||||||
const parsed = new URL(apiBaseUrl);
|
|
||||||
this.origin = parsed.origin;
|
|
||||||
if (
|
|
||||||
!Number.isSafeInteger(requestTimeoutMs) ||
|
|
||||||
requestTimeoutMs < 1 ||
|
|
||||||
requestTimeoutMs > 30_000
|
|
||||||
) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'invalid-input',
|
|
||||||
'provider request timeout is outside the bounded range',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async request(url: string, init: RequestInit): Promise<Response> {
|
|
||||||
const deadline = AbortSignal.timeout(this.requestTimeoutMs);
|
|
||||||
const signal = init.signal == null ? deadline : AbortSignal.any([init.signal, deadline]);
|
|
||||||
try {
|
|
||||||
return await this.fetchImpl(url, { ...init, signal });
|
|
||||||
} catch {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'provider-unavailable',
|
|
||||||
'provider request failed before evidence was available',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async classifyRejectedIdentity(rejected: Response): Promise<never> {
|
|
||||||
if (!isJson(rejected)) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-content-type',
|
|
||||||
'provider credential rejection was not JSON',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const status = rejected.status;
|
|
||||||
await boundedBody(rejected);
|
|
||||||
if (status === 403 || status === 404) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'identity-read-forbidden',
|
|
||||||
'provider denied the identity endpoint; credential capability must be tested in scope',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'credential-rejected',
|
|
||||||
'provider rejected the supplied credential; account existence was not inferred',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
|
|
||||||
const endpoint = 'GET /api/v1/user';
|
|
||||||
const response = await this.request(`${this.origin}/api/v1/user`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: apiAuthorization(resolved),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (response.status === 401 || response.status === 403 || response.status === 404) {
|
|
||||||
return this.classifyRejectedIdentity(response);
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'provider-unavailable',
|
|
||||||
`provider identity request returned HTTP ${response.status.toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parsed = userSchema.safeParse(await jsonObject(response));
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'provider identity object lacked required fields',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
login: parsed.data.login,
|
|
||||||
endpoint,
|
|
||||||
contentType: contentType(response),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async readRepositoryPermission(
|
|
||||||
resolved: ResolvedCredential,
|
|
||||||
repo: string,
|
|
||||||
): Promise<RepositoryPermissionEvidenceDto> {
|
|
||||||
const { owner, name } = repoPath(repo);
|
|
||||||
const endpoint = `GET /api/v1/repos/${owner}/${name}`;
|
|
||||||
const response = await this.request(`${this.origin}/api/v1/repos/${owner}/${name}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: apiAuthorization(resolved),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'provider-unavailable',
|
|
||||||
`provider repository request returned HTTP ${response.status.toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parsed = repoSchema.safeParse(await jsonObject(response));
|
|
||||||
if (!parsed.success || parsed.data.full_name !== repo) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'provider repository object did not identify the requested repository',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
effective: effectivePermission(parsed.data.permissions),
|
|
||||||
endpoint,
|
|
||||||
contentType: contentType(response),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async probeReceivePack(
|
|
||||||
resolved: ResolvedCredential | undefined,
|
|
||||||
repo: string,
|
|
||||||
): Promise<ReceivePackEvidenceDto> {
|
|
||||||
const { owner, name } = repoPath(repo);
|
|
||||||
const headers = new Headers({
|
|
||||||
Accept: RECEIVE_PACK_CONTENT_TYPE,
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
});
|
|
||||||
if (resolved !== undefined) headers.set('Authorization', gitAuthorization(resolved));
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/${owner}/${name}.git/info/refs?service=git-receive-pack`,
|
|
||||||
{ method: 'GET', headers },
|
|
||||||
);
|
|
||||||
const responseType = contentType(response);
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
await boundedBody(response);
|
|
||||||
return {
|
|
||||||
state: 'refused',
|
|
||||||
principal: resolved?.identity ?? null,
|
|
||||||
resolutionId: resolved?.resolutionId ?? null,
|
|
||||||
contentType: responseType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!response.ok || !responseType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
response.ok ? 'unexpected-content-type' : 'provider-unavailable',
|
|
||||||
`receive-pack response was not an advertisement (HTTP ${response.status.toString()})`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const body = new TextDecoder('utf-8', { fatal: true }).decode(await boundedBody(response));
|
|
||||||
if (!body.includes('# service=git-receive-pack')) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'receive-pack advertisement lacked the protocol service preamble',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
state: 'advertised',
|
|
||||||
principal: resolved?.identity ?? null,
|
|
||||||
resolutionId: resolved?.resolutionId ?? null,
|
|
||||||
contentType: responseType,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GiteaGrantProviderAdapter
|
|
||||||
extends GiteaCredentialProviderAdapter
|
|
||||||
implements GiteaGrantProvider
|
|
||||||
{
|
|
||||||
async readBasicIdentity(authority: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
|
|
||||||
const endpoint = 'GET /api/v1/user';
|
|
||||||
const response = await this.request(`${this.origin}/api/v1/user`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
await boundedBody(response);
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'credential-rejected',
|
|
||||||
'delegated Basic authority was rejected',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parsed = userSchema.safeParse(await jsonObject(response));
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'delegated Basic identity response was malformed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { login: parsed.data.login, endpoint, contentType: contentType(response) };
|
|
||||||
}
|
|
||||||
|
|
||||||
async grantCollaborator(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
identity: string,
|
|
||||||
repo: string,
|
|
||||||
permission: RepositoryPermission,
|
|
||||||
): Promise<void> {
|
|
||||||
const { owner, name } = repoPath(repo);
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'Content-Type': JSON_CONTENT_TYPE,
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ permission }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await boundedBody(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
response.status === 401 || response.status === 403
|
|
||||||
? 'credential-rejected'
|
|
||||||
: 'provider-unavailable',
|
|
||||||
`provider grant request returned HTTP ${response.status.toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async readCollaboratorPermission(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
identity: string,
|
|
||||||
repo: string,
|
|
||||||
): Promise<CollaboratorPermissionEvidenceDto> {
|
|
||||||
const { owner, name } = repoPath(repo);
|
|
||||||
const endpoint = `GET /api/v1/repos/${owner}/${name}/collaborators/${identity}/permission`;
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/repos/${owner}/${name}/collaborators/${encodeURIComponent(identity)}/permission`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
await boundedBody(response);
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'readback-missing',
|
|
||||||
`collaborator permission read-back returned HTTP ${response.status.toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parsed = collaboratorPermissionSchema.safeParse(await jsonObject(response));
|
|
||||||
if (!parsed.success || parsed.data.user.login !== identity) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'collaborator permission object did not identify the declared subject',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
identity: parsed.data.user.login,
|
|
||||||
permission: parsed.data.permission,
|
|
||||||
endpoint,
|
|
||||||
contentType: contentType(response),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async readOrganizationMembership(
|
|
||||||
subject: ResolvedCredential,
|
|
||||||
organization: string,
|
|
||||||
): Promise<OrganizationMembershipEvidenceDto> {
|
|
||||||
const endpoint = `GET /api/v1/users/${subject.identity}/orgs`;
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/users/${encodeURIComponent(subject.identity)}/orgs`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: apiAuthorization(subject),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
await boundedBody(response);
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
response.status === 401 || response.status === 403
|
|
||||||
? 'scope-not-evaluable'
|
|
||||||
: 'provider-unavailable',
|
|
||||||
`organization membership read-back returned HTTP ${response.status.toString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parsed = z.array(organizationSchema).safeParse(await jsonObject(response));
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'organization membership response was not an organization array',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
state: parsed.data.some((entry): boolean => entry.username === organization)
|
|
||||||
? 'present'
|
|
||||||
: 'absent',
|
|
||||||
endpoint,
|
|
||||||
contentType: contentType(response),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GiteaTeamGrantProviderAdapter
|
|
||||||
extends GiteaGrantProviderAdapter
|
|
||||||
implements GiteaTeamGrantProvider
|
|
||||||
{
|
|
||||||
async resolveTeam(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
organization: string,
|
|
||||||
team: string,
|
|
||||||
): Promise<TeamResolutionEvidence> {
|
|
||||||
const endpoint = `GET /api/v1/orgs/${organization}/teams`;
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/orgs/${encodeURIComponent(organization)}/teams`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
await boundedBody(response);
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'provider-unavailable',
|
|
||||||
'team list was unavailable',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parsed = z.array(teamSchema).safeParse(await jsonObject(response));
|
|
||||||
const matches = parsed.success
|
|
||||||
? parsed.data.filter((entry): boolean => entry.name === team)
|
|
||||||
: [];
|
|
||||||
if (matches.length !== 1 || matches[0] === undefined) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'readback-missing',
|
|
||||||
'team did not resolve uniquely',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { ...matches[0], endpoint, contentType: contentType(response) };
|
|
||||||
}
|
|
||||||
|
|
||||||
async listTeamRepositories(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
teamId: number,
|
|
||||||
): Promise<TeamRepositorySetEvidence> {
|
|
||||||
const endpoint = `GET /api/v1/teams/${teamId.toString()}/repos`;
|
|
||||||
const repositories: string[] = [];
|
|
||||||
let observedType = '';
|
|
||||||
for (let page = 1; page <= 100; page += 1) {
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/teams/${teamId.toString()}/repos?limit=50&page=${page.toString()}`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
await boundedBody(response);
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'readback-missing',
|
|
||||||
'team repository set was unavailable',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
observedType = contentType(response);
|
|
||||||
const parsed = z.array(repoSchema).safeParse(await jsonObject(response));
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'team repository set was not a repository array',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
repositories.push(...parsed.data.map((repo): string => repo.full_name));
|
|
||||||
if (parsed.data.length < 50) {
|
|
||||||
return { repositories, endpoint, contentType: observedType };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'readback-missing',
|
|
||||||
'team repository set exceeded the pagination bound',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addTeamMember(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
teamId: number,
|
|
||||||
identity: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.putTeamPath(
|
|
||||||
authority,
|
|
||||||
`/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeTeamMember(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
teamId: number,
|
|
||||||
identity: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await boundedBody(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'provider-unavailable',
|
|
||||||
'team member rollback failed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async attachTeamRepository(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
teamId: number,
|
|
||||||
repo: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const { owner, name } = repoPath(repo);
|
|
||||||
await this.putTeamPath(authority, `/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async detachTeamRepository(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
teamId: number,
|
|
||||||
repo: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const { owner, name } = repoPath(repo);
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await boundedBody(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'provider-unavailable',
|
|
||||||
'team repository rollback failed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async putTeamPath(authority: ResolvedCredential, path: string): Promise<void> {
|
|
||||||
const response = await this.request(`${this.origin}${path}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await boundedBody(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'provider-unavailable',
|
|
||||||
'team grant mutation failed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async readTeamMember(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
teamId: number,
|
|
||||||
identity: string,
|
|
||||||
): Promise<PresenceEvidence> {
|
|
||||||
return this.readPresence(
|
|
||||||
authority,
|
|
||||||
`GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`,
|
|
||||||
(value: unknown): boolean => {
|
|
||||||
const parsed = userSchema.safeParse(value);
|
|
||||||
return parsed.success && parsed.data.login === identity;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async readTeamRepository(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
teamId: number,
|
|
||||||
repo: string,
|
|
||||||
): Promise<PresenceEvidence> {
|
|
||||||
const { owner, name } = repoPath(repo);
|
|
||||||
return this.readPresence(
|
|
||||||
authority,
|
|
||||||
`GET /api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`,
|
|
||||||
(value: unknown): boolean => {
|
|
||||||
const parsed = repoSchema.safeParse(value);
|
|
||||||
return parsed.success && parsed.data.full_name === repo;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async readPresence(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
endpoint: string,
|
|
||||||
matchesExpectedObject: (value: unknown) => boolean,
|
|
||||||
): Promise<PresenceEvidence> {
|
|
||||||
const response = await this.request(`${this.origin}${endpoint.slice(4)}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (response.status === 404) {
|
|
||||||
await boundedBody(response);
|
|
||||||
return { state: 'absent', endpoint, contentType: contentType(response) };
|
|
||||||
}
|
|
||||||
if (!response.ok || !isJson(response)) {
|
|
||||||
await boundedBody(response);
|
|
||||||
throw new CredentialProviderEvidenceError('readback-missing', 'team read-back failed');
|
|
||||||
}
|
|
||||||
if (!matchesExpectedObject(await jsonObject(response))) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'team read-back did not identify the requested object',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { state: 'present', endpoint, contentType: contentType(response) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function basicAuthorization(authority: ResolvedCredential): string {
|
|
||||||
const prefix = Buffer.from(`${authority.identity}:`, 'utf8');
|
|
||||||
const material = Buffer.concat([prefix, Buffer.from(authority.secret)]);
|
|
||||||
try {
|
|
||||||
return `Basic ${material.toString('base64')}`;
|
|
||||||
} finally {
|
|
||||||
prefix.fill(0);
|
|
||||||
material.fill(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GiteaLifecycleProviderAdapter
|
|
||||||
extends GiteaCredentialProviderAdapter
|
|
||||||
implements GiteaLifecycleProvider
|
|
||||||
{
|
|
||||||
async readBasicIdentity(authority: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
|
|
||||||
const endpoint = 'GET /api/v1/user';
|
|
||||||
const response = await this.request(`${this.origin}/api/v1/user`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
await boundedBody(response);
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'credential-rejected',
|
|
||||||
'delegated Basic authority was rejected',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parsed = userSchema.safeParse(await jsonObject(response));
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'delegated Basic identity response was malformed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { login: parsed.data.login, endpoint, contentType: contentType(response) };
|
|
||||||
}
|
|
||||||
|
|
||||||
async mintToken(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
identity: string,
|
|
||||||
name: string,
|
|
||||||
scopes: readonly string[],
|
|
||||||
): Promise<MintedToken> {
|
|
||||||
const endpoint = `POST /api/v1/users/${identity}/tokens`;
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
'Content-Type': JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ name, scopes }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
await boundedBody(response);
|
|
||||||
throw new CredentialProviderEvidenceError('provider-unavailable', 'token mint failed');
|
|
||||||
}
|
|
||||||
const parsed = tokenObjectSchema.safeParse(await jsonObject(response));
|
|
||||||
const secret = parsed.success ? (parsed.data.sha1 ?? parsed.data.token) : undefined;
|
|
||||||
if (!parsed.success || secret === undefined || parsed.data.name !== name) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'minted token object lacked the requested name or secret',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
secret: new TextEncoder().encode(secret),
|
|
||||||
evidence: {
|
|
||||||
name: parsed.data.name,
|
|
||||||
scopes: parsed.data.scopes,
|
|
||||||
endpoint,
|
|
||||||
contentType: contentType(response),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async readToken(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
identity: string,
|
|
||||||
name: string,
|
|
||||||
): Promise<TokenObjectEvidenceDto> {
|
|
||||||
const endpoint = `GET /api/v1/users/${identity}/tokens`;
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
await boundedBody(response);
|
|
||||||
throw new CredentialProviderEvidenceError('readback-missing', 'token list read-back failed');
|
|
||||||
}
|
|
||||||
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
|
|
||||||
const matches = parsed.success
|
|
||||||
? parsed.data.filter((token): boolean => token.name === name)
|
|
||||||
: [];
|
|
||||||
if (matches.length !== 1 || matches[0] === undefined) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'readback-missing',
|
|
||||||
'minted token did not resolve uniquely by name',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: matches[0].name,
|
|
||||||
scopes: matches[0].scopes,
|
|
||||||
endpoint,
|
|
||||||
contentType: contentType(response),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async tokenExists(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
identity: string,
|
|
||||||
name: string,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
await boundedBody(response);
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'readback-missing',
|
|
||||||
'token absence read-back failed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'unexpected-provider-shape',
|
|
||||||
'token absence read-back was malformed',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return parsed.data.some((token): boolean => token.name === name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void> {
|
|
||||||
const response = await this.request(
|
|
||||||
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
Accept: JSON_CONTENT_TYPE,
|
|
||||||
Authorization: basicAuthorization(authority),
|
|
||||||
'User-Agent': USER_AGENT,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
await boundedBody(response);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new CredentialProviderEvidenceError('mutation-state-unknown', 'token revoke failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type {
|
|
||||||
CredentialAuditResultDto,
|
|
||||||
CredentialMutationState,
|
|
||||||
CredentialOutcome,
|
|
||||||
CredentialReasonDto,
|
|
||||||
CredentialSubjectDto,
|
|
||||||
CredentialValidationEvidenceDto,
|
|
||||||
RepositoryPermission,
|
|
||||||
} from './credential-result.dto.js';
|
|
||||||
|
|
||||||
export interface CollaboratorPermissionEvidenceDto {
|
|
||||||
readonly identity: string;
|
|
||||||
readonly permission: RepositoryPermission;
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrganizationMembershipEvidenceDto {
|
|
||||||
readonly state: 'present' | 'absent';
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialGrantEvidenceDto extends CredentialValidationEvidenceDto {
|
|
||||||
readonly collaboratorPermission: CollaboratorPermissionEvidenceDto | null;
|
|
||||||
readonly organizationMembership: OrganizationMembershipEvidenceDto | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialGrantResultDto {
|
|
||||||
readonly schemaVersion: 1;
|
|
||||||
readonly operation: 'grant';
|
|
||||||
readonly outcome: CredentialOutcome;
|
|
||||||
readonly exitCode: 0 | 10 | 20 | 30;
|
|
||||||
readonly retryable: boolean;
|
|
||||||
readonly subject: CredentialSubjectDto;
|
|
||||||
readonly mutation: CredentialMutationState;
|
|
||||||
readonly reason: CredentialReasonDto;
|
|
||||||
readonly evidence: CredentialGrantEvidenceDto;
|
|
||||||
readonly audit: CredentialAuditResultDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectGrantRequestDto extends CredentialSubjectDto {
|
|
||||||
readonly permission: RepositoryPermission;
|
|
||||||
readonly readOnlyControlIdentity: string;
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import { listCredentialJournals } from './audit-journal.js';
|
|
||||||
import { grantDirectRepositoryPermission } from './grant.js';
|
|
||||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
||||||
import type { GiteaGrantProvider } from './grant.js';
|
|
||||||
import type { CredentialValidationDependencies } from './validate.js';
|
|
||||||
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
const authority: ResolvedCredential = Object.freeze({
|
|
||||||
identity: 'provisioner',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
resolutionId: 'authority',
|
|
||||||
secret: new TextEncoder().encode('authority-canary'),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function stateRoot(): Promise<string> {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-grant-'));
|
|
||||||
return join(cleanup, 'state');
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
function validationDependencies(permission: 'read' | 'write'): CredentialValidationDependencies {
|
|
||||||
const subject: ResolvedCredential = Object.freeze({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
resolutionId: 'subject',
|
|
||||||
secret: new TextEncoder().encode('subject-canary'),
|
|
||||||
});
|
|
||||||
const control: ResolvedCredential = Object.freeze({
|
|
||||||
identity: 'read-control',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
resolutionId: 'control',
|
|
||||||
secret: new TextEncoder().encode('control-canary'),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
estateRegistry: { matches: (): boolean => true },
|
|
||||||
resolver: {
|
|
||||||
async resolve(identity: string): Promise<ResolvedCredential | undefined> {
|
|
||||||
if (identity === 'seat-name') return subject;
|
|
||||||
if (identity === 'read-control') return control;
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
provider: {
|
|
||||||
async readIdentity(resolved: ResolvedCredential) {
|
|
||||||
return {
|
|
||||||
login: resolved.identity,
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readRepositoryPermission(resolved: ResolvedCredential) {
|
|
||||||
return {
|
|
||||||
effective: resolved.identity === 'seat-name' ? permission : 'read',
|
|
||||||
endpoint: 'GET /api/v1/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async probeReceivePack(resolved: ResolvedCredential | undefined) {
|
|
||||||
const subjectWrite = resolved?.identity === 'seat-name' && permission === 'write';
|
|
||||||
return {
|
|
||||||
state: subjectWrite ? 'advertised' : 'refused',
|
|
||||||
principal: resolved?.identity ?? null,
|
|
||||||
resolutionId: resolved?.resolutionId ?? null,
|
|
||||||
contentType: subjectWrite ? 'application/x-git-receive-pack-advertisement' : 'text/plain',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('direct repository grant', (): void => {
|
|
||||||
it('opens the journal before mutation and accepts only matching provider read-back', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
const provider: GiteaGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async grantCollaborator(): Promise<void> {
|
|
||||||
expect((await listCredentialJournals(root))[0]?.state).toBe('open');
|
|
||||||
},
|
|
||||||
async readCollaboratorPermission() {
|
|
||||||
return {
|
|
||||||
identity: 'seat-name',
|
|
||||||
permission: 'write',
|
|
||||||
endpoint: 'GET /api/v1/repos/owner/repo/collaborators/seat-name/permission',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'absent',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await grantDirectRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validationDependencies('write'),
|
|
||||||
{ stateRoot: root, actor: 'provisioner' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('ok');
|
|
||||||
expect(result.mutation).toBe('applied');
|
|
||||||
expect(result.evidence.repositoryPermission?.effective).toBe('write');
|
|
||||||
expect(result.evidence.organizationMembership?.state).toBe('absent');
|
|
||||||
expect(result.audit.state).toBe('sealed');
|
|
||||||
const [sealed] = await listCredentialJournals(root);
|
|
||||||
const source = await readFile(sealed?.path ?? '', 'utf8');
|
|
||||||
expect(source).toContain('"phase":"mutation"');
|
|
||||||
expect(source).toContain('"decision":"collaborator-grant-applied"');
|
|
||||||
expect(source).toContain('"decision":"identity-verified"');
|
|
||||||
expect(source).toContain('"decision":"organization-member-absent"');
|
|
||||||
expect(source).toContain('"decision":"transport-write-verified"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
const provider: GiteaGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async grantCollaborator(): Promise<void> {},
|
|
||||||
async readCollaboratorPermission() {
|
|
||||||
throw new Error('read-back unavailable');
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
throw new Error('must not be reached');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await grantDirectRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validationDependencies('write'),
|
|
||||||
{ stateRoot: root, actor: 'provisioner' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.mutation).toBe('applied');
|
|
||||||
expect(result.reason.code).toBe('readback-missing');
|
|
||||||
expect(result.audit.journalId).not.toBeNull();
|
|
||||||
expect(result.audit.state).toBe('sealed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise<void> => {
|
|
||||||
const root = await stateRoot();
|
|
||||||
const provider: GiteaGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async grantCollaborator(): Promise<void> {},
|
|
||||||
async readCollaboratorPermission() {
|
|
||||||
return {
|
|
||||||
identity: 'seat-name',
|
|
||||||
permission: 'read',
|
|
||||||
endpoint: 'GET /api/v1/repos/owner/repo/collaborators/seat-name/permission',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'absent',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await grantDirectRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validationDependencies('read'),
|
|
||||||
{ stateRoot: root, actor: 'provisioner' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('permission-evidence-disagrees');
|
|
||||||
expect(result.mutation).toBe('applied');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
|
||||||
import type {
|
|
||||||
CredentialValidationDependencies,
|
|
||||||
ResolvedCredential,
|
|
||||||
} from './credential-provider.dto.js';
|
|
||||||
import type { RepositoryPermission } from './credential-result.dto.js';
|
|
||||||
import type {
|
|
||||||
CollaboratorPermissionEvidenceDto,
|
|
||||||
CredentialGrantResultDto,
|
|
||||||
DirectGrantRequestDto,
|
|
||||||
OrganizationMembershipEvidenceDto,
|
|
||||||
} from './grant.dto.js';
|
|
||||||
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
|
||||||
|
|
||||||
export interface GiteaGrantProvider {
|
|
||||||
readBasicIdentity(authority: ResolvedCredential): Promise<{
|
|
||||||
readonly login: string;
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
}>;
|
|
||||||
grantCollaborator(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
identity: string,
|
|
||||||
repo: string,
|
|
||||||
permission: RepositoryPermission,
|
|
||||||
): Promise<void>;
|
|
||||||
readCollaboratorPermission(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
identity: string,
|
|
||||||
repo: string,
|
|
||||||
): Promise<CollaboratorPermissionEvidenceDto>;
|
|
||||||
readOrganizationMembership(
|
|
||||||
subject: ResolvedCredential,
|
|
||||||
organization: string,
|
|
||||||
): Promise<OrganizationMembershipEvidenceDto>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CredentialGrantExecutionError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly code: string,
|
|
||||||
public readonly mutation: 'none' | 'unknown' | 'applied',
|
|
||||||
public readonly journalId: string,
|
|
||||||
) {
|
|
||||||
super(`Credential grant control failed: code=${code}`);
|
|
||||||
this.name = 'CredentialGrantExecutionError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialGrantServiceOptions {
|
|
||||||
readonly stateRoot: string;
|
|
||||||
readonly actor: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exitFor(outcome: CredentialGrantResultDto['outcome']): 0 | 10 | 20 | 30 {
|
|
||||||
if (outcome === 'ok') return 0;
|
|
||||||
if (outcome === 'refused') return 10;
|
|
||||||
if (outcome === 'error') return 20;
|
|
||||||
return 30;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function grantDirectRepositoryPermission(
|
|
||||||
request: DirectGrantRequestDto,
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
grantProvider: GiteaGrantProvider,
|
|
||||||
validationDependencies: CredentialValidationDependencies,
|
|
||||||
options: CredentialGrantServiceOptions,
|
|
||||||
): Promise<CredentialGrantResultDto> {
|
|
||||||
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
|
||||||
operation: 'grant',
|
|
||||||
actor: options.actor,
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
repo: request.repo,
|
|
||||||
});
|
|
||||||
await journal.recordIntent('provider-grant');
|
|
||||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
|
||||||
try {
|
|
||||||
const authorityIdentity = await grantProvider.readBasicIdentity(authority);
|
|
||||||
if (authorityIdentity.login !== options.actor) {
|
|
||||||
await journal.seal('refused', 'provider-identity-mismatch');
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
operation: 'grant',
|
|
||||||
outcome: 'refused',
|
|
||||||
exitCode: 10,
|
|
||||||
retryable: false,
|
|
||||||
subject: {
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
repo: request.repo,
|
|
||||||
},
|
|
||||||
mutation: 'none',
|
|
||||||
reason: {
|
|
||||||
code: 'provider-identity-mismatch',
|
|
||||||
message: 'Delegated grant authority did not authenticate as the explicit audit actor.',
|
|
||||||
},
|
|
||||||
evidence: {
|
|
||||||
providerIdentity: authorityIdentity,
|
|
||||||
tokenCapabilities: {
|
|
||||||
state: 'not-measured',
|
|
||||||
scopes: [],
|
|
||||||
source: 'runtime-not-authorized',
|
|
||||||
},
|
|
||||||
repositoryPermission: null,
|
|
||||||
writeDifferential: null,
|
|
||||||
collaboratorPermission: null,
|
|
||||||
organizationMembership: null,
|
|
||||||
},
|
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: authorityIdentity.endpoint,
|
|
||||||
contentType: authorityIdentity.contentType,
|
|
||||||
decision: 'identity-verified',
|
|
||||||
});
|
|
||||||
mutation = 'unknown';
|
|
||||||
await grantProvider.grantCollaborator(
|
|
||||||
authority,
|
|
||||||
request.identity,
|
|
||||||
request.repo,
|
|
||||||
request.permission,
|
|
||||||
);
|
|
||||||
mutation = 'applied';
|
|
||||||
await journal.recordMutation('collaborator-grant-applied');
|
|
||||||
|
|
||||||
const collaborator = await grantProvider.readCollaboratorPermission(
|
|
||||||
authority,
|
|
||||||
request.identity,
|
|
||||||
request.repo,
|
|
||||||
);
|
|
||||||
const subject = await validationDependencies.resolver.resolve(
|
|
||||||
request.identity,
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
);
|
|
||||||
const organization = request.repo.split('/')[0] ?? '';
|
|
||||||
const organizationMembership =
|
|
||||||
subject === undefined
|
|
||||||
? null
|
|
||||||
: await grantProvider.readOrganizationMembership(subject, organization);
|
|
||||||
const validation =
|
|
||||||
request.permission === 'read'
|
|
||||||
? await evaluateGiteaReadValidation(request, validationDependencies)
|
|
||||||
: await evaluateGiteaWriteValidation(request, validationDependencies);
|
|
||||||
|
|
||||||
if (organizationMembership !== null) {
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: organizationMembership.endpoint,
|
|
||||||
contentType: organizationMembership.contentType,
|
|
||||||
decision:
|
|
||||||
organizationMembership.state === 'present'
|
|
||||||
? 'organization-member-present'
|
|
||||||
: 'organization-member-absent',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (validation.evidence.providerIdentity !== null) {
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: validation.evidence.providerIdentity.endpoint,
|
|
||||||
contentType: validation.evidence.providerIdentity.contentType,
|
|
||||||
decision: 'identity-verified',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (validation.evidence.repositoryPermission !== null) {
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: validation.evidence.repositoryPermission.endpoint,
|
|
||||||
contentType: validation.evidence.repositoryPermission.contentType,
|
|
||||||
decision: `permission-${validation.evidence.repositoryPermission.effective}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (validation.evidence.writeDifferential !== null) {
|
|
||||||
await journal.recordMutation('transport-write-verified');
|
|
||||||
}
|
|
||||||
|
|
||||||
const readBackMatches =
|
|
||||||
collaborator.identity === request.identity &&
|
|
||||||
collaborator.permission === request.permission &&
|
|
||||||
validation.outcome === 'ok' &&
|
|
||||||
validation.evidence.repositoryPermission?.effective === request.permission;
|
|
||||||
const outcome: CredentialGrantResultDto['outcome'] = readBackMatches ? 'ok' : 'indeterminate';
|
|
||||||
const reasonCode = readBackMatches ? 'grant-verified' : 'permission-evidence-disagrees';
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: collaborator.endpoint,
|
|
||||||
contentType: collaborator.contentType,
|
|
||||||
decision: `permission-${collaborator.permission}`,
|
|
||||||
});
|
|
||||||
await journal.seal(outcome, reasonCode);
|
|
||||||
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
operation: 'grant',
|
|
||||||
outcome,
|
|
||||||
exitCode: exitFor(outcome),
|
|
||||||
retryable: false,
|
|
||||||
subject: {
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
repo: request.repo,
|
|
||||||
},
|
|
||||||
mutation: 'applied',
|
|
||||||
reason: {
|
|
||||||
code: reasonCode,
|
|
||||||
message: readBackMatches
|
|
||||||
? 'Grant matched every required provider read-back.'
|
|
||||||
: 'Grant mutation completed but provider permission evidence disagreed.',
|
|
||||||
},
|
|
||||||
evidence: {
|
|
||||||
...validation.evidence,
|
|
||||||
collaboratorPermission: collaborator,
|
|
||||||
organizationMembership,
|
|
||||||
},
|
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
|
||||||
};
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof CredentialJournalError) {
|
|
||||||
throw new CredentialGrantExecutionError(error.code, mutation, journal.journalId());
|
|
||||||
}
|
|
||||||
const reasonCode = mutation === 'applied' ? 'readback-missing' : 'mutation-state-unknown';
|
|
||||||
try {
|
|
||||||
await journal.seal('indeterminate', reasonCode);
|
|
||||||
} catch (journalError: unknown) {
|
|
||||||
if (journalError instanceof CredentialJournalError) {
|
|
||||||
throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId());
|
|
||||||
}
|
|
||||||
throw journalError;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
operation: 'grant',
|
|
||||||
outcome: 'indeterminate',
|
|
||||||
exitCode: 30,
|
|
||||||
retryable: false,
|
|
||||||
subject: {
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
repo: request.repo,
|
|
||||||
},
|
|
||||||
mutation,
|
|
||||||
reason: {
|
|
||||||
code: reasonCode,
|
|
||||||
message: 'Grant mutation state was preserved after provider evidence failed.',
|
|
||||||
},
|
|
||||||
evidence: {
|
|
||||||
providerIdentity: null,
|
|
||||||
tokenCapabilities: {
|
|
||||||
state: 'not-measured',
|
|
||||||
scopes: [],
|
|
||||||
source: 'runtime-not-authorized',
|
|
||||||
},
|
|
||||||
repositoryPermission: null,
|
|
||||||
writeDifferential: null,
|
|
||||||
collaboratorPermission: null,
|
|
||||||
organizationMembership: null,
|
|
||||||
},
|
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { CredentialOutcome } from './credential-result.dto.js';
|
|
||||||
|
|
||||||
export type CredentialLifecycleOperation =
|
|
||||||
| 'provision'
|
|
||||||
| 'wire'
|
|
||||||
| 'get'
|
|
||||||
| 'whoami'
|
|
||||||
| 'list'
|
|
||||||
| 'rotate'
|
|
||||||
| 'revoke'
|
|
||||||
| 'audit';
|
|
||||||
|
|
||||||
export interface TokenObjectEvidenceDto {
|
|
||||||
readonly name: string;
|
|
||||||
readonly scopes: readonly string[];
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialLifecycleResultDto {
|
|
||||||
readonly schemaVersion: 1;
|
|
||||||
readonly operation: CredentialLifecycleOperation;
|
|
||||||
readonly outcome: CredentialOutcome;
|
|
||||||
readonly exitCode: 0 | 10 | 20 | 30;
|
|
||||||
readonly retryable: boolean;
|
|
||||||
readonly subject: {
|
|
||||||
readonly identity: string;
|
|
||||||
readonly estate: string;
|
|
||||||
readonly host: string;
|
|
||||||
readonly repo: null;
|
|
||||||
};
|
|
||||||
readonly mutation: 'none' | 'unknown' | 'applied';
|
|
||||||
readonly reason: { readonly code: string; readonly message: string };
|
|
||||||
readonly evidence: {
|
|
||||||
readonly providerIdentity: string | null;
|
|
||||||
readonly token: TokenObjectEvidenceDto | null;
|
|
||||||
readonly teaLogin: {
|
|
||||||
readonly name: string;
|
|
||||||
readonly host: string;
|
|
||||||
readonly state: 'registered' | 'not-measured';
|
|
||||||
} | null;
|
|
||||||
readonly identities: readonly string[];
|
|
||||||
readonly journalIds: readonly string[];
|
|
||||||
};
|
|
||||||
readonly audit: {
|
|
||||||
readonly journalId: string | null;
|
|
||||||
readonly state: 'not-started' | 'open' | 'sealed';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,645 +0,0 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { mkdtemp, mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
||||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
|
||||||
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
|
||||||
import { FileCredentialStore } from './file-credential-store.js';
|
|
||||||
import {
|
|
||||||
acquireCredentialLifecycleLock,
|
|
||||||
CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL,
|
|
||||||
credentialLifecycleLocksDirectory,
|
|
||||||
provisionCredential,
|
|
||||||
revokeCredential,
|
|
||||||
type GiteaLifecycleProvider,
|
|
||||||
} from './lifecycle.js';
|
|
||||||
import { TeaLoginStore } from './tea-login-store.js';
|
|
||||||
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fixture(): Promise<{
|
|
||||||
root: string;
|
|
||||||
store: FileCredentialStore;
|
|
||||||
teaStore: TeaLoginStore;
|
|
||||||
}> {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-lifecycle-'));
|
|
||||||
const tokens = join(cleanup, 'tokens');
|
|
||||||
await mkdir(tokens, { mode: 0o700 });
|
|
||||||
const registry = parseCredentialEstateRegistry(
|
|
||||||
JSON.stringify({
|
|
||||||
version: 1,
|
|
||||||
estates: [
|
|
||||||
{
|
|
||||||
name: 'homelab',
|
|
||||||
readOnlyControlIdentity: 'control',
|
|
||||||
hosts: [
|
|
||||||
{
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
provider: 'gitea',
|
|
||||||
apiBaseUrl: 'https://git.example.invalid',
|
|
||||||
tokenPrefix: 'gitea-example',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
root: join(cleanup, 'state'),
|
|
||||||
store: new FileCredentialStore(tokens, registry),
|
|
||||||
teaStore: new TeaLoginStore(join(cleanup, 'tea', 'config.yml')),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const authority: ResolvedCredential = Object.freeze({
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
resolutionId: 'basic-authority',
|
|
||||||
secret: new TextEncoder().encode('password-canary'),
|
|
||||||
});
|
|
||||||
|
|
||||||
function provider(): GiteaLifecycleProvider {
|
|
||||||
return {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return { login: 'seat', endpoint: 'GET /api/v1/user', contentType: 'application/json' };
|
|
||||||
},
|
|
||||||
async mintToken(_authority, _identity, name, scopes) {
|
|
||||||
return {
|
|
||||||
secret: new TextEncoder().encode('minted-token-canary'),
|
|
||||||
evidence: {
|
|
||||||
name,
|
|
||||||
scopes,
|
|
||||||
endpoint: 'POST /api/v1/users/seat/tokens',
|
|
||||||
contentType: 'application/json',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readToken(_authority, _identity, name) {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
endpoint: 'GET /api/v1/users/seat/tokens',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async revokeToken(): Promise<void> {},
|
|
||||||
async tokenExists(): Promise<boolean> {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('credential lifecycle', (): void => {
|
|
||||||
it('pins lifecycle flock as cooperative serialization rather than authorization', (): void => {
|
|
||||||
expect(CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL).toStrictEqual({
|
|
||||||
purpose: 'cooperative-serialization',
|
|
||||||
authorizationBoundary: 'provider-authority',
|
|
||||||
generationPreconditions: 'optimistic-cooperating-mutators',
|
|
||||||
hostileSameUidFilesystem: 'out-of-scope-deferred',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a concurrent same-identity lifecycle mutation across state roots', async (): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
const lock = await acquireCredentialLifecycleLock('seat', 'homelab', 'git.example.invalid');
|
|
||||||
try {
|
|
||||||
const result = await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'mosaic-seat-contended',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{
|
|
||||||
stateRoot: `${root}-other`,
|
|
||||||
actor: 'seat',
|
|
||||||
lifecycleLock: {
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
async release(): Promise<void> {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
outcome: 'refused',
|
|
||||||
mutation: 'none',
|
|
||||||
reason: { code: 'concurrent-mutation' },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await lock.release();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps cooperative lifecycle locks below the validated user home, not shared tmp', (): void => {
|
|
||||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
|
||||||
expect(locksDirectory).not.toMatch(/^\/tmp(?:\/|$)/);
|
|
||||||
expect(locksDirectory).toMatch(/\/\.local\/state\/mosaic\/credential-lifecycle-locks$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
['../seat', 'homelab', 'git.example.invalid'],
|
|
||||||
['seat', '../homelab', 'git.example.invalid'],
|
|
||||||
['seat', 'homelab', '../git.example.invalid'],
|
|
||||||
])(
|
|
||||||
'rejects traversal before constructing a lifecycle lock path',
|
|
||||||
async (identity, estate, host): Promise<void> => {
|
|
||||||
await expect(acquireCredentialLifecycleLock(identity, estate, host)).rejects.toMatchObject({
|
|
||||||
code: 'mutation-lock-unavailable',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('returns a structured error when the lifecycle lock path cannot be opened', async (): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
|
||||||
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
|
|
||||||
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--open-failure-seat.lock');
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
await symlink('/dev/null', lockPath);
|
|
||||||
try {
|
|
||||||
const result = await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'open-failure-seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'mosaic-open-failure-seat',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'open-failure-seat' },
|
|
||||||
);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
outcome: 'error',
|
|
||||||
mutation: 'none',
|
|
||||||
reason: { code: 'mutation-lock-unavailable' },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['provision', 'revoke'] as const)(
|
|
||||||
'returns an open structured %s result when lock failure cannot be sealed',
|
|
||||||
async (operation): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
const identity = `seal-failure-${operation}`;
|
|
||||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
|
||||||
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
|
|
||||||
const lockPath = join(locksDirectory, `homelab--git.example.invalid--${identity}.lock`);
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
await symlink('/dev/null', lockPath);
|
|
||||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
|
|
||||||
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
const result =
|
|
||||||
operation === 'provision'
|
|
||||||
? await provisionCredential(
|
|
||||||
{
|
|
||||||
identity,
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: `mosaic-${identity}`,
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
{ ...authority, identity },
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: identity },
|
|
||||||
)
|
|
||||||
: await revokeCredential(
|
|
||||||
{ identity, estate: 'homelab', host: 'git.example.invalid' },
|
|
||||||
{ ...authority, identity },
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: identity },
|
|
||||||
);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
outcome: 'error',
|
|
||||||
mutation: 'none',
|
|
||||||
reason: { code: 'journal-recovery-required' },
|
|
||||||
audit: { state: 'open' },
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('returns a sealed structured error when the Tea pre-state cannot be snapshotted', async (): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
await teaStore.put('seat', 'git.example.invalid', new TextEncoder().encode('prior-tea-token'));
|
|
||||||
await writeFile(join(cleanup!, 'tea', 'config.yml'), 'logins: not-an-array\n', { mode: 0o600 });
|
|
||||||
|
|
||||||
const result = await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'mosaic-seat-snapshot-failure',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
outcome: 'error',
|
|
||||||
mutation: 'none',
|
|
||||||
reason: { code: 'credential-snapshot-unavailable' },
|
|
||||||
audit: { state: 'sealed' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts provision only after exact principal and scope read-back', async (): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
const result = await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'mosaic-seat-1',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat', now: (): string => '2026-08-05T00:00:00.000Z' },
|
|
||||||
);
|
|
||||||
expect(result.outcome).toBe('ok');
|
|
||||||
await expect(
|
|
||||||
store.readBinding('seat', 'homelab', 'git.example.invalid'),
|
|
||||||
).resolves.toMatchObject({ providerLogin: 'seat', scopes: ['write:repository'] });
|
|
||||||
expect(JSON.stringify(result)).not.toContain('minted-token-canary');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rolls back a minted token when exact scope read-back disagrees', async (): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
let revoked = false;
|
|
||||||
const lifecycleProvider = provider();
|
|
||||||
lifecycleProvider.readToken = async (_authority, _identity, name) => ({
|
|
||||||
name,
|
|
||||||
scopes: ['admin'],
|
|
||||||
endpoint: 'GET /api/v1/users/seat/tokens',
|
|
||||||
contentType: 'application/json',
|
|
||||||
});
|
|
||||||
lifecycleProvider.revokeToken = async (): Promise<void> => {
|
|
||||||
revoked = true;
|
|
||||||
};
|
|
||||||
lifecycleProvider.tokenExists = async (): Promise<boolean> => false;
|
|
||||||
|
|
||||||
const result = await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'mosaic-seat-bad',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
lifecycleProvider,
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('error');
|
|
||||||
expect(result.mutation).toBe('none');
|
|
||||||
expect(revoked).toBe(true);
|
|
||||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['store', 'tea'] as const)(
|
|
||||||
'removes %s state committed before a post-commit storage failure',
|
|
||||||
async (target): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
if (target === 'store') {
|
|
||||||
const put = store.put.bind(store);
|
|
||||||
store.put = async (binding, secret): Promise<void> => {
|
|
||||||
await put(binding, secret);
|
|
||||||
throw new Error('injected store post-commit failure');
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const put = teaStore.put.bind(teaStore);
|
|
||||||
teaStore.put = async (identity, host, secret): Promise<void> => {
|
|
||||||
await put(identity, host, secret);
|
|
||||||
throw new Error('injected Tea post-commit failure');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: `mosaic-seat-${target}-fault`,
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
|
|
||||||
await expect(
|
|
||||||
store.snapshot('seat', 'homelab', 'git.example.invalid'),
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeUndefined();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('rejects a stale lifecycle write when a competing generation bypasses serialization', async (): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
const put = store.put.bind(store);
|
|
||||||
let injected = false;
|
|
||||||
store.put = async (binding, secret, expectedGeneration): Promise<void> => {
|
|
||||||
if (!injected) {
|
|
||||||
injected = true;
|
|
||||||
await put(
|
|
||||||
{
|
|
||||||
...binding,
|
|
||||||
tokenName: 'competing-generation',
|
|
||||||
createdAt: '2026-08-05T00:00:01.000Z',
|
|
||||||
},
|
|
||||||
new TextEncoder().encode('competing-token'),
|
|
||||||
expectedGeneration,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await put(binding, secret, expectedGeneration);
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'stale-generation',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
outcome: 'indeterminate',
|
|
||||||
mutation: 'applied',
|
|
||||||
reason: { code: 'rollback-incomplete' },
|
|
||||||
});
|
|
||||||
const current = await store.snapshot('seat', 'homelab', 'git.example.invalid');
|
|
||||||
try {
|
|
||||||
expect(current?.binding.tokenName).toBe('competing-generation');
|
|
||||||
expect(Buffer.from(current?.secret ?? []).toString('utf8')).toBe('competing-token');
|
|
||||||
} finally {
|
|
||||||
current?.secret.fill(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['absent', 'divergent'] as const)(
|
|
||||||
'restores an independently %s pre-operation Tea state exactly',
|
|
||||||
async (teaState): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
await store.put(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
providerLogin: 'seat',
|
|
||||||
tokenName: 'old-generation',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
createdAt: '2026-08-05T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
new TextEncoder().encode('old-store-token'),
|
|
||||||
);
|
|
||||||
if (teaState === 'divergent') {
|
|
||||||
await teaStore.put(
|
|
||||||
'seat',
|
|
||||||
'git.example.invalid',
|
|
||||||
new TextEncoder().encode('divergent-tea-token'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const journal = await CredentialAuditJournal.open(root, {
|
|
||||||
operation: 'provision',
|
|
||||||
actor: 'seat',
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: null,
|
|
||||||
});
|
|
||||||
let evidenceWrites = 0;
|
|
||||||
const recordProviderEvidence = journal.recordProviderEvidence.bind(journal);
|
|
||||||
journal.recordProviderEvidence = async (evidence): Promise<void> => {
|
|
||||||
evidenceWrites += 1;
|
|
||||||
if (evidenceWrites === 2) throw new Error('injected post-registration failure');
|
|
||||||
await recordProviderEvidence(evidence);
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'replacement-generation',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat', journal, allowReplace: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
|
|
||||||
if (teaState === 'absent') {
|
|
||||||
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeUndefined();
|
|
||||||
} else {
|
|
||||||
expect(
|
|
||||||
teaStore.matchesSecret(
|
|
||||||
'seat',
|
|
||||||
'git.example.invalid',
|
|
||||||
new TextEncoder().encode('divergent-tea-token'),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('reports an incomplete rollback when Tea removal cannot be verified', async (): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
const journal = await CredentialAuditJournal.open(root, {
|
|
||||||
operation: 'provision',
|
|
||||||
actor: 'seat',
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: null,
|
|
||||||
});
|
|
||||||
let evidenceWrites = 0;
|
|
||||||
const recordProviderEvidence = journal.recordProviderEvidence.bind(journal);
|
|
||||||
journal.recordProviderEvidence = async (evidence): Promise<void> => {
|
|
||||||
evidenceWrites += 1;
|
|
||||||
if (evidenceWrites === 2) throw new Error('injected post-registration failure');
|
|
||||||
await recordProviderEvidence(evidence);
|
|
||||||
};
|
|
||||||
teaStore.restore = async (): Promise<void> => {
|
|
||||||
throw new Error('injected Tea restoration failure');
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'mosaic-seat-rollback',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat', journal },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
outcome: 'indeterminate',
|
|
||||||
mutation: 'applied',
|
|
||||||
reason: { code: 'rollback-incomplete' },
|
|
||||||
});
|
|
||||||
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('revokes at provider before removing the local binding', async (): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'mosaic-seat-1',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat' },
|
|
||||||
);
|
|
||||||
let revoked = false;
|
|
||||||
const lifecycleProvider = provider();
|
|
||||||
lifecycleProvider.revokeToken = async (): Promise<void> => {
|
|
||||||
revoked = true;
|
|
||||||
};
|
|
||||||
const result = await revokeCredential(
|
|
||||||
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
|
|
||||||
authority,
|
|
||||||
lifecycleProvider,
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat' },
|
|
||||||
);
|
|
||||||
expect(result.outcome).toBe('ok');
|
|
||||||
expect(revoked).toBe(true);
|
|
||||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('releases an internally owned journal lock after an append failure', async (): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'mosaic-seat-lock-release',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat' },
|
|
||||||
);
|
|
||||||
const recordProviderEvidence = CredentialAuditJournal.prototype.recordProviderEvidence;
|
|
||||||
CredentialAuditJournal.prototype.recordProviderEvidence = async (): Promise<void> => {
|
|
||||||
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const result = await revokeCredential(
|
|
||||||
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat' },
|
|
||||||
);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
outcome: 'indeterminate',
|
|
||||||
mutation: 'none',
|
|
||||||
audit: { state: 'open' },
|
|
||||||
});
|
|
||||||
if (result.audit.journalId === null) throw new Error('journal id was not returned');
|
|
||||||
const lockPath = join(root, 'journal-locks', `${result.audit.journalId}.lock`);
|
|
||||||
const lockProbe = spawnSync('/usr/bin/flock', ['-n', lockPath, '/bin/true']);
|
|
||||||
expect(lockProbe.status).toBe(0);
|
|
||||||
} finally {
|
|
||||||
CredentialAuditJournal.prototype.recordProviderEvidence = recordProviderEvidence;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise<void> => {
|
|
||||||
const { root, store, teaStore } = await fixture();
|
|
||||||
await provisionCredential(
|
|
||||||
{
|
|
||||||
identity: 'seat',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
tokenName: 'mosaic-seat-1',
|
|
||||||
scopes: ['write:repository'],
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider(),
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat' },
|
|
||||||
);
|
|
||||||
const lifecycleProvider = provider();
|
|
||||||
lifecycleProvider.tokenExists = async (): Promise<boolean> => true;
|
|
||||||
const result = await revokeCredential(
|
|
||||||
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
|
|
||||||
authority,
|
|
||||||
lifecycleProvider,
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
{ stateRoot: root, actor: 'seat' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,736 +0,0 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { timingSafeEqual } from 'node:crypto';
|
|
||||||
import { constants, lstatSync } from 'node:fs';
|
|
||||||
import { open } from 'node:fs/promises';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { ensureManagedDirectory } from '../fleet/secure-file.js';
|
|
||||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
|
||||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
||||||
import { credentialBindingGeneration, type FileCredentialStore } from './file-credential-store.js';
|
|
||||||
import type { TeaLoginStore } from './tea-login-store.js';
|
|
||||||
import type {
|
|
||||||
CredentialLifecycleOperation,
|
|
||||||
CredentialLifecycleResultDto,
|
|
||||||
TokenObjectEvidenceDto,
|
|
||||||
} from './lifecycle.dto.js';
|
|
||||||
|
|
||||||
export interface MintedToken {
|
|
||||||
readonly secret: Uint8Array;
|
|
||||||
readonly evidence: TokenObjectEvidenceDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GiteaLifecycleProvider {
|
|
||||||
readBasicIdentity(authority: ResolvedCredential): Promise<{
|
|
||||||
readonly login: string;
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
}>;
|
|
||||||
mintToken(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
identity: string,
|
|
||||||
name: string,
|
|
||||||
scopes: readonly string[],
|
|
||||||
): Promise<MintedToken>;
|
|
||||||
readToken(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
identity: string,
|
|
||||||
name: string,
|
|
||||||
): Promise<TokenObjectEvidenceDto>;
|
|
||||||
revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void>;
|
|
||||||
tokenExists(authority: ResolvedCredential, identity: string, name: string): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LifecycleRequest {
|
|
||||||
readonly identity: string;
|
|
||||||
readonly estate: string;
|
|
||||||
readonly host: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProvisionRequest extends LifecycleRequest {
|
|
||||||
readonly tokenName: string;
|
|
||||||
readonly scopes: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LifecycleOptions {
|
|
||||||
readonly stateRoot: string;
|
|
||||||
readonly actor: string;
|
|
||||||
readonly now?: () => string;
|
|
||||||
readonly allowReplace?: boolean;
|
|
||||||
readonly journal?: CredentialAuditJournal;
|
|
||||||
readonly deferSuccessSeal?: boolean;
|
|
||||||
readonly lifecycleLock?: CredentialLifecycleLock;
|
|
||||||
readonly expectedStoreGeneration?: string | null;
|
|
||||||
readonly expectedTeaGeneration?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CredentialLifecycleLock {
|
|
||||||
readonly identity: string;
|
|
||||||
readonly estate: string;
|
|
||||||
readonly host: string;
|
|
||||||
release(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeLifecycleLocks = new WeakSet<CredentialLifecycleLock>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This flock coordinates cooperating `mosaic cred` processes only. A process
|
|
||||||
* sharing the Unix uid can replace the pathname/inode, so the lock is never
|
|
||||||
* authorization evidence. Provider authority is the authorization boundary;
|
|
||||||
* generation preconditions are optimistic concurrency for cooperating store
|
|
||||||
* mutators, not atomic CAS against a hostile same-uid filesystem writer.
|
|
||||||
*/
|
|
||||||
export const CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL = Object.freeze({
|
|
||||||
purpose: 'cooperative-serialization',
|
|
||||||
authorizationBoundary: 'provider-authority',
|
|
||||||
generationPreconditions: 'optimistic-cooperating-mutators',
|
|
||||||
hostileSameUidFilesystem: 'out-of-scope-deferred',
|
|
||||||
} as const);
|
|
||||||
|
|
||||||
export class CredentialLifecycleLockError extends Error {
|
|
||||||
constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') {
|
|
||||||
super(code);
|
|
||||||
this.name = 'CredentialLifecycleLockError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do not promote this advisory lock into a same-uid authorization boundary;
|
|
||||||
// CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL is a tested public invariant.
|
|
||||||
export function credentialLifecycleLocksDirectory(): string {
|
|
||||||
return join(homedir(), '.local', 'state', 'mosaic', 'credential-lifecycle-locks');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function acquireCredentialLifecycleLock(
|
|
||||||
identity: string,
|
|
||||||
estate: string,
|
|
||||||
host: string,
|
|
||||||
): Promise<CredentialLifecycleLock> {
|
|
||||||
if (
|
|
||||||
!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(identity) ||
|
|
||||||
!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(estate) ||
|
|
||||||
!/^[a-z0-9][a-z0-9.-]*$/.test(host)
|
|
||||||
) {
|
|
||||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
|
||||||
}
|
|
||||||
const uid = process.getuid?.();
|
|
||||||
if (uid === undefined) throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
|
||||||
const home = homedir();
|
|
||||||
const homeDirectory = lstatSync(home);
|
|
||||||
if (
|
|
||||||
!homeDirectory.isDirectory() ||
|
|
||||||
homeDirectory.isSymbolicLink() ||
|
|
||||||
homeDirectory.uid !== uid ||
|
|
||||||
(homeDirectory.mode & 0o022) !== 0
|
|
||||||
) {
|
|
||||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
|
||||||
}
|
|
||||||
const locksDirectory = credentialLifecycleLocksDirectory();
|
|
||||||
ensureManagedDirectory(home, locksDirectory);
|
|
||||||
const directory = lstatSync(locksDirectory);
|
|
||||||
if (
|
|
||||||
!directory.isDirectory() ||
|
|
||||||
directory.isSymbolicLink() ||
|
|
||||||
directory.uid !== uid ||
|
|
||||||
(directory.mode & 0o077) !== 0
|
|
||||||
) {
|
|
||||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
|
||||||
}
|
|
||||||
const lockPath = join(locksDirectory, `${estate}--${host}--${identity}.lock`);
|
|
||||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
||||||
try {
|
|
||||||
handle = await open(
|
|
||||||
lockPath,
|
|
||||||
constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,
|
|
||||||
0o600,
|
|
||||||
);
|
|
||||||
const file = await handle.stat();
|
|
||||||
if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) {
|
|
||||||
throw new Error('credential lifecycle lock file is unsafe');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await handle?.close().catch((): void => undefined);
|
|
||||||
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
|
||||||
}
|
|
||||||
if (handle === undefined) throw new CredentialLifecycleLockError('mutation-lock-unavailable');
|
|
||||||
const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], {
|
|
||||||
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
|
|
||||||
});
|
|
||||||
if (acquired.error !== undefined || acquired.status !== 0) {
|
|
||||||
await handle.close().catch((): void => undefined);
|
|
||||||
throw new CredentialLifecycleLockError(
|
|
||||||
acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let released = false;
|
|
||||||
const lock: CredentialLifecycleLock = {
|
|
||||||
identity,
|
|
||||||
estate,
|
|
||||||
host,
|
|
||||||
async release(): Promise<void> {
|
|
||||||
if (released) return;
|
|
||||||
released = true;
|
|
||||||
activeLifecycleLocks.delete(lock);
|
|
||||||
await handle.close();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
activeLifecycleLocks.add(lock);
|
|
||||||
return lock;
|
|
||||||
}
|
|
||||||
|
|
||||||
function holdsCredentialLifecycleLock(
|
|
||||||
lock: CredentialLifecycleLock | undefined,
|
|
||||||
identity: string,
|
|
||||||
estate: string,
|
|
||||||
host: string,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
lock !== undefined &&
|
|
||||||
activeLifecycleLocks.has(lock) &&
|
|
||||||
lock.identity === identity &&
|
|
||||||
lock.estate === estate &&
|
|
||||||
lock.host === host
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lifecycleResult(
|
|
||||||
operation: CredentialLifecycleOperation,
|
|
||||||
request: LifecycleRequest,
|
|
||||||
options: {
|
|
||||||
readonly outcome: CredentialLifecycleResultDto['outcome'];
|
|
||||||
readonly mutation: CredentialLifecycleResultDto['mutation'];
|
|
||||||
readonly code: string;
|
|
||||||
readonly message: string;
|
|
||||||
readonly journalId: string | null;
|
|
||||||
readonly auditState: 'not-started' | 'open' | 'sealed';
|
|
||||||
readonly providerIdentity?: string | null;
|
|
||||||
readonly token?: TokenObjectEvidenceDto | null;
|
|
||||||
readonly teaLogin?: CredentialLifecycleResultDto['evidence']['teaLogin'];
|
|
||||||
},
|
|
||||||
): CredentialLifecycleResultDto {
|
|
||||||
const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const;
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
operation,
|
|
||||||
outcome: options.outcome,
|
|
||||||
exitCode: exits[options.outcome],
|
|
||||||
retryable: false,
|
|
||||||
subject: { ...request, repo: null },
|
|
||||||
mutation: options.mutation,
|
|
||||||
reason: { code: options.code, message: options.message },
|
|
||||||
evidence: {
|
|
||||||
providerIdentity: options.providerIdentity ?? null,
|
|
||||||
token: options.token ?? null,
|
|
||||||
teaLogin: options.teaLogin ?? null,
|
|
||||||
identities: [],
|
|
||||||
journalIds: [],
|
|
||||||
},
|
|
||||||
audit: { journalId: options.journalId, state: options.auditState },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function lifecycleLockFailureResult(
|
|
||||||
operation: 'provision' | 'revoke',
|
|
||||||
request: LifecycleRequest,
|
|
||||||
journal: CredentialAuditJournal,
|
|
||||||
code: 'concurrent-mutation' | 'mutation-lock-unavailable',
|
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
|
||||||
try {
|
|
||||||
await journal.seal(code === 'concurrent-mutation' ? 'refused' : 'error', code);
|
|
||||||
return lifecycleResult(operation, request, {
|
|
||||||
outcome: code === 'concurrent-mutation' ? 'refused' : 'error',
|
|
||||||
mutation: 'none',
|
|
||||||
code,
|
|
||||||
message: 'Credential lifecycle mutation lock could not be acquired.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
});
|
|
||||||
} catch (sealError: unknown) {
|
|
||||||
await journal.closeIncomplete().catch((): void => undefined);
|
|
||||||
return lifecycleResult(operation, request, {
|
|
||||||
outcome: 'error',
|
|
||||||
mutation: 'none',
|
|
||||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
|
||||||
message: 'Mutation lock failure could not be sealed durably.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'open',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openLifecycleJournal(
|
|
||||||
operation: 'provision' | 'rotate' | 'revoke',
|
|
||||||
request: LifecycleRequest,
|
|
||||||
options: LifecycleOptions,
|
|
||||||
): Promise<CredentialAuditJournal> {
|
|
||||||
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
|
||||||
operation,
|
|
||||||
actor: options.actor,
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
repo: null,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await journal.recordIntent(`${operation}-requested`);
|
|
||||||
return journal;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
await journal.closeIncomplete().catch((): void => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function provisionCredential(
|
|
||||||
request: ProvisionRequest,
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
provider: GiteaLifecycleProvider,
|
|
||||||
store: FileCredentialStore,
|
|
||||||
teaStore: TeaLoginStore,
|
|
||||||
options: LifecycleOptions,
|
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
|
||||||
const ownsJournal = options.journal === undefined;
|
|
||||||
const journal = options.journal ?? (await openLifecycleJournal('provision', request, options));
|
|
||||||
let ownedLock: CredentialLifecycleLock | undefined;
|
|
||||||
if (
|
|
||||||
!holdsCredentialLifecycleLock(
|
|
||||||
options.lifecycleLock,
|
|
||||||
request.identity,
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
ownedLock = await acquireCredentialLifecycleLock(
|
|
||||||
request.identity,
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const code =
|
|
||||||
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
|
|
||||||
return lifecycleLockFailureResult('provision', request, journal, code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await provisionCredentialLocked(
|
|
||||||
request,
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
store,
|
|
||||||
teaStore,
|
|
||||||
options,
|
|
||||||
journal,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (ownsJournal) await journal.closeIncomplete().catch((): void => undefined);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await ownedLock?.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function provisionCredentialLocked(
|
|
||||||
request: ProvisionRequest,
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
provider: GiteaLifecycleProvider,
|
|
||||||
store: FileCredentialStore,
|
|
||||||
teaStore: TeaLoginStore,
|
|
||||||
options: LifecycleOptions,
|
|
||||||
journal: CredentialAuditJournal,
|
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
|
||||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
|
||||||
let minted: MintedToken | undefined;
|
|
||||||
let failureCode = 'mutation-state-unknown';
|
|
||||||
let mintedStoreGeneration: string | undefined;
|
|
||||||
let prior: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
|
|
||||||
let priorTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
|
|
||||||
try {
|
|
||||||
prior = await store.snapshot(request.identity, request.estate, request.host);
|
|
||||||
priorTea = teaStore.snapshot(request.identity, request.host);
|
|
||||||
} catch {
|
|
||||||
prior?.secret.fill(0);
|
|
||||||
priorTea?.secret.fill(0);
|
|
||||||
try {
|
|
||||||
await journal.seal('error', 'credential-snapshot-unavailable');
|
|
||||||
return lifecycleResult('provision', request, {
|
|
||||||
outcome: 'error',
|
|
||||||
mutation: 'none',
|
|
||||||
code: 'credential-snapshot-unavailable',
|
|
||||||
message: 'Pre-operation credential state could not be snapshotted safely.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
});
|
|
||||||
} catch (sealError: unknown) {
|
|
||||||
await journal.closeIncomplete().catch((): void => undefined);
|
|
||||||
return lifecycleResult('provision', request, {
|
|
||||||
outcome: 'error',
|
|
||||||
mutation: 'none',
|
|
||||||
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
|
|
||||||
message: 'Credential snapshot failure could not be sealed durably.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'open',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(options.expectedStoreGeneration !== undefined &&
|
|
||||||
(prior?.generation ?? null) !== options.expectedStoreGeneration) ||
|
|
||||||
(options.expectedTeaGeneration !== undefined &&
|
|
||||||
(priorTea?.generation ?? null) !== options.expectedTeaGeneration)
|
|
||||||
) {
|
|
||||||
prior?.secret.fill(0);
|
|
||||||
priorTea?.secret.fill(0);
|
|
||||||
await journal.seal('refused', 'concurrent-mutation');
|
|
||||||
return lifecycleResult('provision', request, {
|
|
||||||
outcome: 'refused',
|
|
||||||
mutation: 'none',
|
|
||||||
code: 'concurrent-mutation',
|
|
||||||
message: 'Credential generation changed before the lifecycle transaction began.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (prior !== undefined && options.allowReplace !== true) {
|
|
||||||
try {
|
|
||||||
await journal.seal('refused', 'credential-already-exists');
|
|
||||||
return lifecycleResult('provision', request, {
|
|
||||||
outcome: 'refused',
|
|
||||||
mutation: 'none',
|
|
||||||
code: 'credential-already-exists',
|
|
||||||
message: 'A governed credential already exists; use rotate.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
prior.secret.fill(0);
|
|
||||||
priorTea?.secret.fill(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const identity = await provider.readBasicIdentity(authority);
|
|
||||||
if (identity.login !== request.identity || authority.identity !== request.identity) {
|
|
||||||
await journal.seal('refused', 'provider-identity-mismatch');
|
|
||||||
return lifecycleResult('provision', request, {
|
|
||||||
outcome: 'refused',
|
|
||||||
mutation: 'none',
|
|
||||||
code: 'provider-identity-mismatch',
|
|
||||||
message: 'Delegated Basic authority did not bind the requested principal.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
providerIdentity: identity.login,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: identity.endpoint,
|
|
||||||
contentType: identity.contentType,
|
|
||||||
decision: 'identity-verified',
|
|
||||||
});
|
|
||||||
mutation = 'unknown';
|
|
||||||
minted = await provider.mintToken(
|
|
||||||
authority,
|
|
||||||
request.identity,
|
|
||||||
request.tokenName,
|
|
||||||
request.scopes,
|
|
||||||
);
|
|
||||||
mutation = 'applied';
|
|
||||||
await journal.recordMutation('token-mint-applied');
|
|
||||||
const readBack = await provider.readToken(authority, request.identity, request.tokenName);
|
|
||||||
const expected = [...request.scopes].sort();
|
|
||||||
const actual = [...readBack.scopes].sort();
|
|
||||||
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
|
|
||||||
failureCode = 'scope-not-evaluable';
|
|
||||||
throw new Error('scope read-back disagreed');
|
|
||||||
}
|
|
||||||
const mintedBinding = {
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
providerLogin: identity.login,
|
|
||||||
tokenName: request.tokenName,
|
|
||||||
scopes: readBack.scopes,
|
|
||||||
createdAt: options.now?.() ?? new Date().toISOString(),
|
|
||||||
};
|
|
||||||
mintedStoreGeneration = credentialBindingGeneration(mintedBinding, minted.secret);
|
|
||||||
await store.put(mintedBinding, minted.secret, prior?.generation ?? null);
|
|
||||||
await journal.recordMutation('token-binding-stored');
|
|
||||||
await teaStore.put(request.identity, request.host, minted.secret, priorTea?.generation ?? null);
|
|
||||||
const teaLogin = teaStore.readBack(request.identity, request.host);
|
|
||||||
if (
|
|
||||||
teaLogin === undefined ||
|
|
||||||
!teaStore.matchesSecret(request.identity, request.host, minted.secret)
|
|
||||||
) {
|
|
||||||
failureCode = 'tea-login-missing';
|
|
||||||
throw new Error('Tea login did not resolve exactly');
|
|
||||||
}
|
|
||||||
await journal.recordMutation('tea-login-stored');
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: readBack.endpoint,
|
|
||||||
contentType: readBack.contentType,
|
|
||||||
decision: 'scope-verified',
|
|
||||||
});
|
|
||||||
if (options.deferSuccessSeal !== true) {
|
|
||||||
await journal.seal('ok', 'provision-verified');
|
|
||||||
}
|
|
||||||
return lifecycleResult('provision', request, {
|
|
||||||
outcome: 'ok',
|
|
||||||
mutation: 'applied',
|
|
||||||
code: options.deferSuccessSeal === true ? 'replacement-staged' : 'provision-verified',
|
|
||||||
message:
|
|
||||||
options.deferSuccessSeal === true
|
|
||||||
? 'Replacement was read back and staged under the open rotation transaction.'
|
|
||||||
: 'Provider principal and exact token scopes were read back and stored.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: options.deferSuccessSeal === true ? 'open' : 'sealed',
|
|
||||||
providerIdentity: identity.login,
|
|
||||||
token: readBack,
|
|
||||||
teaLogin: { ...teaLogin, state: 'registered' },
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const journalFailure = error instanceof CredentialJournalError;
|
|
||||||
if (minted === undefined) {
|
|
||||||
if (journalFailure) throw error;
|
|
||||||
await journal.seal('indeterminate', 'provider-unavailable');
|
|
||||||
return lifecycleResult('provision', request, {
|
|
||||||
outcome: 'indeterminate',
|
|
||||||
mutation,
|
|
||||||
code: 'provider-unavailable',
|
|
||||||
message: 'Provider token mint did not complete.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let rollbackComplete = false;
|
|
||||||
try {
|
|
||||||
await provider.revokeToken(authority, request.identity, request.tokenName);
|
|
||||||
if (await provider.tokenExists(authority, request.identity, request.tokenName)) {
|
|
||||||
throw new Error('minted token still exists after rollback');
|
|
||||||
}
|
|
||||||
const expectedStoreGeneration = prior?.generation ?? null;
|
|
||||||
const current = await store.snapshot(request.identity, request.estate, request.host);
|
|
||||||
try {
|
|
||||||
if ((current?.generation ?? null) !== expectedStoreGeneration) {
|
|
||||||
if (
|
|
||||||
mintedStoreGeneration === undefined ||
|
|
||||||
current?.generation !== mintedStoreGeneration
|
|
||||||
) {
|
|
||||||
throw new Error('credential generation changed during rollback');
|
|
||||||
}
|
|
||||||
if (prior === undefined) {
|
|
||||||
await store.remove(
|
|
||||||
request.identity,
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
mintedStoreGeneration,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await store.put(prior.binding, prior.secret, mintedStoreGeneration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
current?.secret.fill(0);
|
|
||||||
}
|
|
||||||
const expectedTeaGeneration = priorTea?.generation ?? null;
|
|
||||||
const currentTea = teaStore.snapshot(request.identity, request.host);
|
|
||||||
try {
|
|
||||||
if ((currentTea?.generation ?? null) !== expectedTeaGeneration) {
|
|
||||||
if (
|
|
||||||
currentTea === undefined ||
|
|
||||||
currentTea.secret.byteLength !== minted.secret.byteLength ||
|
|
||||||
!timingSafeEqual(Buffer.from(currentTea.secret), Buffer.from(minted.secret))
|
|
||||||
) {
|
|
||||||
throw new Error('Tea login generation changed during rollback');
|
|
||||||
}
|
|
||||||
await teaStore.restore(request.identity, request.host, priorTea, currentTea.generation);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
currentTea?.secret.fill(0);
|
|
||||||
}
|
|
||||||
const restored = await store.snapshot(request.identity, request.estate, request.host);
|
|
||||||
try {
|
|
||||||
const storeRestored =
|
|
||||||
prior === undefined
|
|
||||||
? restored === undefined
|
|
||||||
: restored !== undefined &&
|
|
||||||
JSON.stringify(restored.binding) === JSON.stringify(prior.binding) &&
|
|
||||||
restored.secret.byteLength === prior.secret.byteLength &&
|
|
||||||
timingSafeEqual(Buffer.from(restored.secret), Buffer.from(prior.secret));
|
|
||||||
if (!storeRestored || !teaStore.matchesSnapshot(request.identity, request.host, priorTea)) {
|
|
||||||
throw new Error('pre-operation credential state was not restored exactly');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
restored?.secret.fill(0);
|
|
||||||
}
|
|
||||||
rollbackComplete = true;
|
|
||||||
} catch {
|
|
||||||
rollbackComplete = false;
|
|
||||||
} finally {
|
|
||||||
prior?.secret.fill(0);
|
|
||||||
priorTea?.secret.fill(0);
|
|
||||||
}
|
|
||||||
if (journalFailure) {
|
|
||||||
if (rollbackComplete) {
|
|
||||||
await journal.recordMutation('provision-rollback-verified').catch((): void => undefined);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const code = rollbackComplete ? failureCode : 'rollback-incomplete';
|
|
||||||
if (rollbackComplete) await journal.recordMutation('provision-rollback-verified');
|
|
||||||
await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code);
|
|
||||||
return lifecycleResult('provision', request, {
|
|
||||||
outcome: rollbackComplete ? 'error' : 'indeterminate',
|
|
||||||
mutation: rollbackComplete ? 'none' : mutation,
|
|
||||||
code,
|
|
||||||
message: rollbackComplete
|
|
||||||
? 'Provisioning failed and every completed mutation was rolled back.'
|
|
||||||
: 'Provisioning failed and rollback could not be proven complete.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
minted?.secret.fill(0);
|
|
||||||
prior?.secret.fill(0);
|
|
||||||
priorTea?.secret.fill(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function revokeCredential(
|
|
||||||
request: LifecycleRequest,
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
provider: GiteaLifecycleProvider,
|
|
||||||
store: FileCredentialStore,
|
|
||||||
teaStore: TeaLoginStore,
|
|
||||||
options: LifecycleOptions,
|
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
|
||||||
const journal = await openLifecycleJournal('revoke', request, options);
|
|
||||||
let ownedLock: CredentialLifecycleLock | undefined;
|
|
||||||
if (
|
|
||||||
!holdsCredentialLifecycleLock(
|
|
||||||
options.lifecycleLock,
|
|
||||||
request.identity,
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
ownedLock = await acquireCredentialLifecycleLock(
|
|
||||||
request.identity,
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const code =
|
|
||||||
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
|
|
||||||
return lifecycleLockFailureResult('revoke', request, journal, code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await revokeCredentialLocked(request, authority, provider, store, teaStore, journal);
|
|
||||||
} finally {
|
|
||||||
await ownedLock?.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function revokeCredentialLocked(
|
|
||||||
request: LifecycleRequest,
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
provider: GiteaLifecycleProvider,
|
|
||||||
store: FileCredentialStore,
|
|
||||||
teaStore: TeaLoginStore,
|
|
||||||
journal: CredentialAuditJournal,
|
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
|
||||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
|
||||||
let credentialSnapshot: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
|
|
||||||
let teaSnapshot: ReturnType<TeaLoginStore['snapshot']> = undefined;
|
|
||||||
try {
|
|
||||||
credentialSnapshot = await store.snapshot(request.identity, request.estate, request.host);
|
|
||||||
if (credentialSnapshot === undefined) {
|
|
||||||
await journal.seal('refused', 'no-token-for-identity');
|
|
||||||
return lifecycleResult('revoke', request, {
|
|
||||||
outcome: 'refused',
|
|
||||||
mutation: 'none',
|
|
||||||
code: 'no-token-for-identity',
|
|
||||||
message: 'No governed token binding exists for the identity.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const binding = credentialSnapshot.binding;
|
|
||||||
teaSnapshot = teaStore.snapshot(request.identity, request.host);
|
|
||||||
const identity = await provider.readBasicIdentity(authority);
|
|
||||||
if (identity.login !== request.identity || authority.identity !== request.identity) {
|
|
||||||
await journal.seal('refused', 'provider-identity-mismatch');
|
|
||||||
return lifecycleResult('revoke', request, {
|
|
||||||
outcome: 'refused',
|
|
||||||
mutation: 'none',
|
|
||||||
code: 'provider-identity-mismatch',
|
|
||||||
message: 'Delegated Basic authority did not bind the requested principal.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
providerIdentity: identity.login,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: identity.endpoint,
|
|
||||||
contentType: identity.contentType,
|
|
||||||
decision: 'identity-verified',
|
|
||||||
});
|
|
||||||
mutation = 'unknown';
|
|
||||||
await provider.revokeToken(authority, request.identity, binding.tokenName);
|
|
||||||
mutation = 'applied';
|
|
||||||
await journal.recordMutation('token-revoke-applied');
|
|
||||||
if (await provider.tokenExists(authority, request.identity, binding.tokenName)) {
|
|
||||||
await journal.seal('indeterminate', 'revoke-readback-missing');
|
|
||||||
return lifecycleResult('revoke', request, {
|
|
||||||
outcome: 'indeterminate',
|
|
||||||
mutation,
|
|
||||||
code: 'revoke-readback-missing',
|
|
||||||
message: 'Provider still returned the token after revocation acknowledgement.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await teaStore.remove(request.identity, request.host, teaSnapshot?.generation ?? null);
|
|
||||||
if (teaStore.readBack(request.identity, request.host) !== undefined) {
|
|
||||||
throw new Error('Tea login still exists after revocation');
|
|
||||||
}
|
|
||||||
await journal.recordMutation('tea-login-removed');
|
|
||||||
await store.remove(
|
|
||||||
request.identity,
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
credentialSnapshot.generation,
|
|
||||||
);
|
|
||||||
await journal.seal('ok', 'revoke-verified');
|
|
||||||
return lifecycleResult('revoke', request, {
|
|
||||||
outcome: 'ok',
|
|
||||||
mutation,
|
|
||||||
code: 'revoke-verified',
|
|
||||||
message: 'Provider token revocation completed before local binding removal.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof CredentialJournalError) {
|
|
||||||
await journal.closeIncomplete().catch((): void => undefined);
|
|
||||||
return lifecycleResult('revoke', request, {
|
|
||||||
outcome: 'indeterminate',
|
|
||||||
mutation,
|
|
||||||
code: error.code,
|
|
||||||
message: 'Audit persistence failed; inspect the durable open journal before recovery.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'open',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await journal.seal('indeterminate', 'mutation-state-unknown');
|
|
||||||
return lifecycleResult('revoke', request, {
|
|
||||||
outcome: 'indeterminate',
|
|
||||||
mutation,
|
|
||||||
code: 'mutation-state-unknown',
|
|
||||||
message: 'Revocation mutation state could not be established completely.',
|
|
||||||
journalId: journal.journalId(),
|
|
||||||
auditState: 'sealed',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
credentialSnapshot?.secret.fill(0);
|
|
||||||
teaSnapshot?.secret.fill(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
import { TeaLoginStore, TeaLoginStoreError } from './tea-login-store.js';
|
|
||||||
|
|
||||||
let root: string | undefined;
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (root !== undefined) await rm(root, { recursive: true, force: true });
|
|
||||||
root = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('host-bound Tea login store', (): void => {
|
|
||||||
it('coordinates concurrent cooperating updates and preserves one identity on two hosts', async (): Promise<void> => {
|
|
||||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
|
||||||
const store = new TeaLoginStore(join(root, 'tea', 'config.yml'));
|
|
||||||
await Promise.all([
|
|
||||||
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
|
||||||
store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
store.matchesSecret('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
store.matchesSecret('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
|
|
||||||
).toBe(true);
|
|
||||||
await store.remove('seat', 'git.one.invalid');
|
|
||||||
expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined();
|
|
||||||
expect(store.readBack('seat', 'git.two.invalid')).toEqual({
|
|
||||||
name: 'seat--git.two.invalid',
|
|
||||||
host: 'git.two.invalid',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('snapshots and restores the exact host-bound Tea record fields', async (): Promise<void> => {
|
|
||||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
|
||||||
const configPath = join(root, 'tea', 'config.yml');
|
|
||||||
const store = new TeaLoginStore(configPath);
|
|
||||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('original-token'));
|
|
||||||
const customized = (await readFile(configPath, 'utf8')).replace(
|
|
||||||
'default: false',
|
|
||||||
'default: true\n extension-field: preserved',
|
|
||||||
);
|
|
||||||
await writeFile(configPath, customized, { mode: 0o600 });
|
|
||||||
const snapshot = store.snapshot('seat', 'git.one.invalid');
|
|
||||||
expect(snapshot).toBeDefined();
|
|
||||||
|
|
||||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('replacement-token'));
|
|
||||||
await store.restore('seat', 'git.one.invalid', snapshot);
|
|
||||||
|
|
||||||
expect(store.matchesSnapshot('seat', 'git.one.invalid', snapshot)).toBe(true);
|
|
||||||
expect(await readFile(configPath, 'utf8')).toContain('extension-field: preserved');
|
|
||||||
snapshot?.secret.fill(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps generation stable across recursive metadata key reordering', async (): Promise<void> => {
|
|
||||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
|
||||||
const configPath = join(root, 'tea', 'config.yml');
|
|
||||||
const store = new TeaLoginStore(configPath);
|
|
||||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
|
||||||
const baseline = await readFile(configPath, 'utf8');
|
|
||||||
await writeFile(
|
|
||||||
configPath,
|
|
||||||
baseline.replace(
|
|
||||||
'default: false',
|
|
||||||
'default: false\n extension:\n zebra: last\n __proto__: one\n nested:\n second: 2\n first: 1',
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
const first = store.snapshot('seat', 'git.one.invalid');
|
|
||||||
await writeFile(
|
|
||||||
configPath,
|
|
||||||
baseline.replace(
|
|
||||||
'default: false',
|
|
||||||
'extension:\n nested:\n first: 1\n second: 2\n __proto__: one\n zebra: last\n default: false',
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
const reordered = store.snapshot('seat', 'git.one.invalid');
|
|
||||||
await writeFile(
|
|
||||||
configPath,
|
|
||||||
baseline.replace(
|
|
||||||
'default: false',
|
|
||||||
'extension:\n nested:\n first: 9\n second: 2\n zebra: last\n default: false',
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
const changed = store.snapshot('seat', 'git.one.invalid');
|
|
||||||
await writeFile(
|
|
||||||
configPath,
|
|
||||||
baseline.replace(
|
|
||||||
'default: false',
|
|
||||||
'extension:\n nested:\n first: 1\n second: 2\n __proto__: two\n zebra: last\n default: false',
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
const prototypeNamedFieldChanged = store.snapshot('seat', 'git.one.invalid');
|
|
||||||
|
|
||||||
expect(reordered?.generation).toBe(first?.generation);
|
|
||||||
expect(changed?.generation).not.toBe(first?.generation);
|
|
||||||
expect(prototypeNamedFieldChanged?.generation).not.toBe(first?.generation);
|
|
||||||
first?.secret.fill(0);
|
|
||||||
reordered?.secret.fill(0);
|
|
||||||
changed?.secret.fill(0);
|
|
||||||
prototypeNamedFieldChanged?.secret.fill(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['.nan', '.inf', '-.inf'] as const)(
|
|
||||||
'rejects non-finite YAML metadata value %s while accepting explicit null',
|
|
||||||
async (nonFiniteValue): Promise<void> => {
|
|
||||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
|
||||||
const configPath = join(root, 'tea', 'config.yml');
|
|
||||||
const store = new TeaLoginStore(configPath);
|
|
||||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
|
||||||
const baseline = await readFile(configPath, 'utf8');
|
|
||||||
await writeFile(
|
|
||||||
configPath,
|
|
||||||
baseline.replace(
|
|
||||||
'default: false',
|
|
||||||
`default: false\n extension-field: ${nonFiniteValue}`,
|
|
||||||
),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(() => store.snapshot('seat', 'git.one.invalid')).toThrowError(TeaLoginStoreError);
|
|
||||||
expect(() => store.snapshot('seat', 'git.one.invalid')).toThrow(/code=tea-config-invalid/);
|
|
||||||
|
|
||||||
await writeFile(
|
|
||||||
configPath,
|
|
||||||
baseline.replace('default: false', 'default: false\n extension-field: null'),
|
|
||||||
{ mode: 0o600 },
|
|
||||||
);
|
|
||||||
const explicitNull = store.snapshot('seat', 'git.one.invalid');
|
|
||||||
expect(explicitNull).toBeDefined();
|
|
||||||
explicitNull?.secret.fill(0);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['put', 'remove'] as const)(
|
|
||||||
'removes secret-bearing temporary files when %s fails before rename',
|
|
||||||
async (operation): Promise<void> => {
|
|
||||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
|
||||||
const configPath = join(root, 'tea', 'config.yml');
|
|
||||||
const baseline = new TeaLoginStore(configPath);
|
|
||||||
if (operation === 'remove') {
|
|
||||||
await baseline.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
|
||||||
}
|
|
||||||
const store = new TeaLoginStore(configPath, {
|
|
||||||
beforeRename: async (candidate): Promise<void> => {
|
|
||||||
if (candidate === operation) throw new Error('injected pre-rename failure');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (operation === 'put') {
|
|
||||||
await expect(
|
|
||||||
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
|
|
||||||
).rejects.toThrow('injected pre-rename failure');
|
|
||||||
} else {
|
|
||||||
await expect(store.remove('seat', 'git.one.invalid')).rejects.toThrow(
|
|
||||||
'injected pre-rename failure',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect((await readdir(join(root, 'tea'))).filter((name) => name.endsWith('.tmp'))).toEqual(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('preserves unrelated Tea configuration and rejects permissive secret reads', async (): Promise<void> => {
|
|
||||||
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
|
|
||||||
const configPath = join(root, 'tea', 'config.yml');
|
|
||||||
const store = new TeaLoginStore(configPath);
|
|
||||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
|
||||||
const original = await readFile(configPath, 'utf8');
|
|
||||||
await writeFile(configPath, `preferences:\n color: true\n${original}`, { mode: 0o600 });
|
|
||||||
|
|
||||||
await store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two'));
|
|
||||||
await store.remove('seat', 'git.one.invalid');
|
|
||||||
expect(await readFile(configPath, 'utf8')).toContain('color: true');
|
|
||||||
|
|
||||||
await chmod(configPath, 0o644);
|
|
||||||
expect(() => store.resolve('seat', 'homelab', 'git.two.invalid')).toThrow(
|
|
||||||
/tea-config-insecure/,
|
|
||||||
);
|
|
||||||
expect(() => store.readBack('seat', 'git.two.invalid')).toThrow(/tea-config-insecure/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
||||||
import { open, rename, unlink } from 'node:fs/promises';
|
|
||||||
import { dirname } from 'node:path';
|
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
|
||||||
import { parse, stringify } from 'yaml';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
|
|
||||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
||||||
|
|
||||||
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
|
|
||||||
|
|
||||||
export class TeaLoginStoreError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly code: string,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(`Tea login store rejected: code=${code} ${message}`);
|
|
||||||
this.name = 'TeaLoginStoreError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TeaLoginRecord {
|
|
||||||
readonly name: string;
|
|
||||||
readonly url: string;
|
|
||||||
readonly token: string;
|
|
||||||
readonly user: string;
|
|
||||||
readonly default: boolean;
|
|
||||||
readonly [key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TeaConfig {
|
|
||||||
readonly logins: TeaLoginRecord[];
|
|
||||||
readonly [key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loginSchema = z
|
|
||||||
.object({
|
|
||||||
name: z.string().regex(SAFE_NAME),
|
|
||||||
url: z.string().url(),
|
|
||||||
token: z.string().min(1),
|
|
||||||
user: z.string().regex(SAFE_NAME),
|
|
||||||
default: z.boolean().default(false),
|
|
||||||
})
|
|
||||||
.passthrough();
|
|
||||||
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough();
|
|
||||||
|
|
||||||
async function syncDirectory(path: string): Promise<void> {
|
|
||||||
const handle = await open(path, 'r');
|
|
||||||
try {
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>>> {
|
|
||||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
|
||||||
try {
|
|
||||||
return await open(path, 'wx', 0o600);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error;
|
|
||||||
await new Promise<void>((resolve): void => {
|
|
||||||
setTimeout(resolve, 10);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new TeaLoginStoreError(
|
|
||||||
'conflicting-credential-mutation',
|
|
||||||
'Tea configuration lock did not become available',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loginName(identity: string, host: string): string {
|
|
||||||
return `${identity}--${host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPrivate(snapshot: { readonly mode: number; readonly uid: number }): void {
|
|
||||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
|
||||||
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function missing(error: unknown): boolean {
|
|
||||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeaLoginSnapshot {
|
|
||||||
readonly fields: Readonly<Record<string, unknown>>;
|
|
||||||
readonly secret: Uint8Array;
|
|
||||||
readonly generation: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CanonicalJsonValue =
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| null
|
|
||||||
| CanonicalJsonValue[]
|
|
||||||
| CanonicalJsonObject;
|
|
||||||
|
|
||||||
interface CanonicalJsonObject {
|
|
||||||
[key: string]: CanonicalJsonValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonicalizeGenerationValue(value: unknown): CanonicalJsonValue {
|
|
||||||
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
if (!Number.isFinite(value)) {
|
|
||||||
throw new TeaLoginStoreError(
|
|
||||||
'tea-config-invalid',
|
|
||||||
'Tea login metadata contains a non-finite number',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((entry: unknown): CanonicalJsonValue => canonicalizeGenerationValue(entry));
|
|
||||||
}
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
const entries = Object.entries(value)
|
|
||||||
.sort(([left], [right]): number => (left < right ? -1 : left > right ? 1 : 0))
|
|
||||||
.flatMap(([key, entry]): [string, CanonicalJsonValue][] =>
|
|
||||||
entry === undefined ? [] : [[key, canonicalizeGenerationValue(entry)]],
|
|
||||||
);
|
|
||||||
return Object.fromEntries(entries);
|
|
||||||
}
|
|
||||||
throw new TeaLoginStoreError(
|
|
||||||
'tea-config-invalid',
|
|
||||||
'Tea login metadata is outside canonical JSON values',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function canonicalGenerationJson(value: object): string {
|
|
||||||
return JSON.stringify(canonicalizeGenerationValue(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotFromConfig(
|
|
||||||
config: TeaConfig,
|
|
||||||
identity: string,
|
|
||||||
host: string,
|
|
||||||
): TeaLoginSnapshot | undefined {
|
|
||||||
const matches = config.logins.filter(
|
|
||||||
(login): boolean => login.name === loginName(identity, host) && login.url === `https://${host}`,
|
|
||||||
);
|
|
||||||
if (matches.length === 0) return undefined;
|
|
||||||
if (matches.length !== 1 || matches[0] === undefined) {
|
|
||||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea login binding is ambiguous');
|
|
||||||
}
|
|
||||||
const { token, ...fields } = matches[0];
|
|
||||||
const secret = new TextEncoder().encode(token);
|
|
||||||
const generation = createHash('sha256')
|
|
||||||
.update(canonicalGenerationJson(fields))
|
|
||||||
.update('\0')
|
|
||||||
.update(secret)
|
|
||||||
.digest('hex');
|
|
||||||
return { fields: structuredClone(fields), secret, generation };
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertExpectedGeneration(
|
|
||||||
config: TeaConfig,
|
|
||||||
identity: string,
|
|
||||||
host: string,
|
|
||||||
expectedGeneration: string | null | undefined,
|
|
||||||
): void {
|
|
||||||
if (expectedGeneration === undefined) return;
|
|
||||||
const current = snapshotFromConfig(config, identity, host);
|
|
||||||
try {
|
|
||||||
if ((current?.generation ?? null) !== expectedGeneration) {
|
|
||||||
throw new TeaLoginStoreError(
|
|
||||||
'tea-generation-mismatch',
|
|
||||||
'Tea login generation changed before mutation',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
current?.secret.fill(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TeaLoginStoreRuntimeOptions {
|
|
||||||
readonly beforeRename?: (
|
|
||||||
operation: 'put' | 'remove' | 'restore',
|
|
||||||
tempPath: string,
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TeaLoginStore {
|
|
||||||
constructor(
|
|
||||||
private readonly configPath: string,
|
|
||||||
private readonly runtime: TeaLoginStoreRuntimeOptions = {},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private async commit(
|
|
||||||
operation: 'put' | 'remove' | 'restore',
|
|
||||||
config: TeaConfig,
|
|
||||||
directory: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
|
||||||
try {
|
|
||||||
const handle = await open(temp, 'wx', 0o600);
|
|
||||||
try {
|
|
||||||
await handle.writeFile(stringify(config), 'utf8');
|
|
||||||
await handle.sync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
await this.runtime.beforeRename?.(operation, temp);
|
|
||||||
await rename(temp, this.configPath);
|
|
||||||
await syncDirectory(directory);
|
|
||||||
} finally {
|
|
||||||
await unlink(temp).catch((): void => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async put(
|
|
||||||
identity: string,
|
|
||||||
host: string,
|
|
||||||
secret: Uint8Array,
|
|
||||||
expectedGeneration?: string | null,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
|
|
||||||
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
|
|
||||||
}
|
|
||||||
const directory = dirname(this.configPath);
|
|
||||||
ensureManagedDirectory(directory, directory);
|
|
||||||
const lockPath = `${this.configPath}.lock`;
|
|
||||||
const lock = await acquireLock(lockPath);
|
|
||||||
try {
|
|
||||||
let current: TeaConfig = { logins: [] };
|
|
||||||
try {
|
|
||||||
const snapshot = readRegularFileSecure(this.configPath, {
|
|
||||||
root: directory,
|
|
||||||
maxBytes: 1024 * 1024,
|
|
||||||
});
|
|
||||||
assertPrivate(snapshot);
|
|
||||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
|
||||||
if (!decoded.success) {
|
|
||||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
|
||||||
}
|
|
||||||
current = decoded.data;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!missing(error)) throw error;
|
|
||||||
}
|
|
||||||
assertExpectedGeneration(current, identity, host, expectedGeneration);
|
|
||||||
const token = Buffer.from(secret).toString('utf8');
|
|
||||||
const record: TeaLoginRecord = {
|
|
||||||
name: loginName(identity, host),
|
|
||||||
url: `https://${host}`,
|
|
||||||
token,
|
|
||||||
user: identity,
|
|
||||||
default: false,
|
|
||||||
};
|
|
||||||
const logins = current.logins.filter(
|
|
||||||
(login): boolean =>
|
|
||||||
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
|
||||||
);
|
|
||||||
logins.push(record);
|
|
||||||
await this.commit('put', { ...current, logins }, directory);
|
|
||||||
} finally {
|
|
||||||
await lock.close();
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshot(identity: string, host: string): TeaLoginSnapshot | undefined {
|
|
||||||
const directory = dirname(this.configPath);
|
|
||||||
let snapshot;
|
|
||||||
try {
|
|
||||||
snapshot = readRegularFileSecure(this.configPath, {
|
|
||||||
root: directory,
|
|
||||||
maxBytes: 1024 * 1024,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (missing(error)) return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
assertPrivate(snapshot);
|
|
||||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
|
||||||
if (!decoded.success) {
|
|
||||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
|
||||||
}
|
|
||||||
return snapshotFromConfig(decoded.data, identity, host);
|
|
||||||
}
|
|
||||||
|
|
||||||
matchesSnapshot(identity: string, host: string, expected: TeaLoginSnapshot | undefined): boolean {
|
|
||||||
const actual = this.snapshot(identity, host);
|
|
||||||
try {
|
|
||||||
if (actual === undefined || expected === undefined) return actual === expected;
|
|
||||||
return (
|
|
||||||
isDeepStrictEqual(actual.fields, expected.fields) &&
|
|
||||||
actual.secret.byteLength === expected.secret.byteLength &&
|
|
||||||
timingSafeEqual(Buffer.from(actual.secret), Buffer.from(expected.secret))
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
actual?.secret.fill(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async restore(
|
|
||||||
identity: string,
|
|
||||||
host: string,
|
|
||||||
snapshot: TeaLoginSnapshot | undefined,
|
|
||||||
expectedGeneration?: string | null,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
|
|
||||||
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
|
|
||||||
}
|
|
||||||
const directory = dirname(this.configPath);
|
|
||||||
ensureManagedDirectory(directory, directory);
|
|
||||||
const lockPath = `${this.configPath}.lock`;
|
|
||||||
const lock = await acquireLock(lockPath);
|
|
||||||
try {
|
|
||||||
let current: TeaConfig = { logins: [] };
|
|
||||||
try {
|
|
||||||
const currentSnapshot = readRegularFileSecure(this.configPath, {
|
|
||||||
root: directory,
|
|
||||||
maxBytes: 1024 * 1024,
|
|
||||||
});
|
|
||||||
assertPrivate(currentSnapshot);
|
|
||||||
const decoded = configSchema.safeParse(parse(currentSnapshot.content.toString('utf8')));
|
|
||||||
if (!decoded.success) {
|
|
||||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
|
||||||
}
|
|
||||||
current = decoded.data;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!missing(error)) throw error;
|
|
||||||
if (
|
|
||||||
snapshot === undefined &&
|
|
||||||
(expectedGeneration === undefined || expectedGeneration === null)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
assertExpectedGeneration(current, identity, host, expectedGeneration);
|
|
||||||
const logins = current.logins.filter(
|
|
||||||
(login): boolean =>
|
|
||||||
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
|
||||||
);
|
|
||||||
if (snapshot !== undefined) {
|
|
||||||
const restored = loginSchema.parse({
|
|
||||||
...structuredClone(snapshot.fields),
|
|
||||||
token: Buffer.from(snapshot.secret).toString('utf8'),
|
|
||||||
});
|
|
||||||
if (restored.name !== loginName(identity, host) || restored.url !== `https://${host}`) {
|
|
||||||
throw new TeaLoginStoreError(
|
|
||||||
'tea-config-invalid',
|
|
||||||
'Tea snapshot does not match the requested identity and host',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
logins.push(restored);
|
|
||||||
}
|
|
||||||
await this.commit('restore', { ...current, logins }, directory);
|
|
||||||
} finally {
|
|
||||||
await lock.close();
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
|
|
||||||
const directory = dirname(this.configPath);
|
|
||||||
let snapshot;
|
|
||||||
try {
|
|
||||||
snapshot = readRegularFileSecure(this.configPath, {
|
|
||||||
root: directory,
|
|
||||||
maxBytes: 1024 * 1024,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (missing(error)) return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
assertPrivate(snapshot);
|
|
||||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
|
||||||
if (!decoded.success) return undefined;
|
|
||||||
const matches = decoded.data.logins.filter(
|
|
||||||
(login): boolean =>
|
|
||||||
login.name === loginName(identity, host) &&
|
|
||||||
login.url === `https://${host}` &&
|
|
||||||
login.user === identity,
|
|
||||||
);
|
|
||||||
if (matches.length !== 1 || matches[0] === undefined) return undefined;
|
|
||||||
return Object.freeze({
|
|
||||||
identity,
|
|
||||||
estate,
|
|
||||||
host,
|
|
||||||
resolutionId: randomUUID(),
|
|
||||||
secret: new TextEncoder().encode(matches[0].token),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
matchesSecret(identity: string, host: string, secret: Uint8Array): boolean {
|
|
||||||
const resolved = this.resolve(identity, 'binding-check', host);
|
|
||||||
if (resolved === undefined || resolved.secret.byteLength !== secret.byteLength) return false;
|
|
||||||
return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret));
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(identity: string, host: string, expectedGeneration?: string | null): Promise<void> {
|
|
||||||
const directory = dirname(this.configPath);
|
|
||||||
const lockPath = `${this.configPath}.lock`;
|
|
||||||
const lock = await acquireLock(lockPath);
|
|
||||||
try {
|
|
||||||
let snapshot;
|
|
||||||
try {
|
|
||||||
snapshot = readRegularFileSecure(this.configPath, {
|
|
||||||
root: directory,
|
|
||||||
maxBytes: 1024 * 1024,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (missing(error)) {
|
|
||||||
if (expectedGeneration !== undefined && expectedGeneration !== null) {
|
|
||||||
throw new TeaLoginStoreError(
|
|
||||||
'tea-generation-mismatch',
|
|
||||||
'Tea login generation changed before removal',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
assertPrivate(snapshot);
|
|
||||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
|
||||||
if (!decoded.success) {
|
|
||||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
|
||||||
}
|
|
||||||
assertExpectedGeneration(decoded.data, identity, host, expectedGeneration);
|
|
||||||
const logins = decoded.data.logins.filter(
|
|
||||||
(login): boolean =>
|
|
||||||
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
|
||||||
);
|
|
||||||
await this.commit('remove', { ...decoded.data, logins }, directory);
|
|
||||||
} finally {
|
|
||||||
await lock.close();
|
|
||||||
await unlink(lockPath).catch((): void => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
readBack(
|
|
||||||
identity: string,
|
|
||||||
host: string,
|
|
||||||
): { readonly name: string; readonly host: string } | undefined {
|
|
||||||
const directory = dirname(this.configPath);
|
|
||||||
let snapshot;
|
|
||||||
try {
|
|
||||||
snapshot = readRegularFileSecure(this.configPath, { root: directory, maxBytes: 1024 * 1024 });
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (missing(error)) return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
assertPrivate(snapshot);
|
|
||||||
const decoded: unknown = parse(snapshot.content.toString('utf8'));
|
|
||||||
if (
|
|
||||||
typeof decoded !== 'object' ||
|
|
||||||
decoded === null ||
|
|
||||||
!('logins' in decoded) ||
|
|
||||||
!Array.isArray(decoded.logins)
|
|
||||||
)
|
|
||||||
return undefined;
|
|
||||||
const matches = decoded.logins.filter((value: unknown): value is TeaLoginRecord => {
|
|
||||||
if (typeof value !== 'object' || value === null) return false;
|
|
||||||
return (
|
|
||||||
'name' in value &&
|
|
||||||
value.name === loginName(identity, host) &&
|
|
||||||
'url' in value &&
|
|
||||||
value.url === `https://${host}` &&
|
|
||||||
'user' in value &&
|
|
||||||
value.user === identity
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return matches.length === 1 ? { name: loginName(identity, host), host } : undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,926 +0,0 @@
|
|||||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
|
||||||
import { grantTeamRepositoryPermission, type GiteaTeamGrantProvider } from './team-grant.js';
|
|
||||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
||||||
import type { CredentialValidationDependencies } from './validate.js';
|
|
||||||
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
const authority: ResolvedCredential = Object.freeze({
|
|
||||||
identity: 'provisioner',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
resolutionId: 'authority',
|
|
||||||
secret: new TextEncoder().encode('authority-canary'),
|
|
||||||
});
|
|
||||||
const subject: ResolvedCredential = Object.freeze({
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
resolutionId: 'subject',
|
|
||||||
secret: new TextEncoder().encode('subject-canary'),
|
|
||||||
});
|
|
||||||
const control: ResolvedCredential = Object.freeze({
|
|
||||||
identity: 'read-control',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
resolutionId: 'control',
|
|
||||||
secret: new TextEncoder().encode('control-canary'),
|
|
||||||
});
|
|
||||||
|
|
||||||
function validation(): CredentialValidationDependencies {
|
|
||||||
return {
|
|
||||||
estateRegistry: { matches: (): boolean => true },
|
|
||||||
resolver: {
|
|
||||||
async resolve(identity: string) {
|
|
||||||
return identity === 'seat-name' ? subject : control;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
provider: {
|
|
||||||
async readIdentity(resolved: ResolvedCredential) {
|
|
||||||
return {
|
|
||||||
login: resolved.identity,
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readRepositoryPermission(resolved: ResolvedCredential) {
|
|
||||||
return {
|
|
||||||
effective: resolved.identity === 'seat-name' ? 'write' : 'read',
|
|
||||||
endpoint: 'GET /api/v1/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async probeReceivePack(resolved: ResolvedCredential | undefined) {
|
|
||||||
const write = resolved?.identity === 'seat-name';
|
|
||||||
return {
|
|
||||||
state: write ? 'advertised' : 'refused',
|
|
||||||
principal: resolved?.identity ?? null,
|
|
||||||
resolutionId: resolved?.resolutionId ?? null,
|
|
||||||
contentType: write ? 'application/x-git-receive-pack-advertisement' : 'text/plain',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('team repository grant', (): void => {
|
|
||||||
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
||||||
let repositoryReads = 0;
|
|
||||||
const provider: GiteaTeamGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async resolveTeam() {
|
|
||||||
return {
|
|
||||||
id: 7,
|
|
||||||
name: 'writers',
|
|
||||||
permission: 'write',
|
|
||||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async listTeamRepositories() {
|
|
||||||
repositoryReads += 1;
|
|
||||||
return {
|
|
||||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async addTeamMember(): Promise<void> {},
|
|
||||||
async removeTeamMember(): Promise<void> {},
|
|
||||||
async attachTeamRepository(): Promise<void> {},
|
|
||||||
async detachTeamRepository(): Promise<void> {},
|
|
||||||
async readTeamMember() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readTeamRepository() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await grantTeamRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
team: 'writers',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validation(),
|
|
||||||
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('ok');
|
|
||||||
expect(result.evidence.organizationMembership?.state).toBe('present');
|
|
||||||
expect(result.evidence.teamMembership?.state).toBe('present');
|
|
||||||
expect(result.evidence.teamRepository?.state).toBe('present');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('durably reports indeterminate when team-lock release cannot be verified', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
||||||
let repositoryReads = 0;
|
|
||||||
const provider: GiteaTeamGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async resolveTeam() {
|
|
||||||
return {
|
|
||||||
id: 7,
|
|
||||||
name: 'writers',
|
|
||||||
permission: 'write',
|
|
||||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async listTeamRepositories() {
|
|
||||||
repositoryReads += 1;
|
|
||||||
return {
|
|
||||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async addTeamMember(): Promise<void> {},
|
|
||||||
async removeTeamMember(): Promise<void> {},
|
|
||||||
async attachTeamRepository(): Promise<void> {},
|
|
||||||
async detachTeamRepository(): Promise<void> {},
|
|
||||||
async readTeamMember() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readTeamRepository() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const stateRoot = join(cleanup, 'state');
|
|
||||||
|
|
||||||
const result = await grantTeamRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
team: 'writers',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validation(),
|
|
||||||
{
|
|
||||||
stateRoot,
|
|
||||||
actor: 'provisioner',
|
|
||||||
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
|
||||||
throw new Error('injected close failure');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('mutation-lock-release-failed');
|
|
||||||
expect(result.mutation).toBe('applied');
|
|
||||||
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
|
||||||
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
|
||||||
expect(journal).toContain('"outcome":"indeterminate"');
|
|
||||||
expect(journal).toContain('"reasonCode":"mutation-lock-release-failed"');
|
|
||||||
expect(journal).toContain('"decision":"permission-write"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never compensates a later cooperating mutation after release when final seal fails', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
||||||
let repositoryReads = 0;
|
|
||||||
let memberPresent = false;
|
|
||||||
let repositoryPresent = false;
|
|
||||||
let memberRemovals = 0;
|
|
||||||
let repositoryDetachments = 0;
|
|
||||||
const provider: GiteaTeamGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async resolveTeam() {
|
|
||||||
return {
|
|
||||||
id: 7,
|
|
||||||
name: 'writers',
|
|
||||||
permission: 'write',
|
|
||||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async listTeamRepositories() {
|
|
||||||
repositoryReads += 1;
|
|
||||||
return {
|
|
||||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async addTeamMember(): Promise<void> {
|
|
||||||
memberPresent = true;
|
|
||||||
},
|
|
||||||
async removeTeamMember(): Promise<void> {
|
|
||||||
memberRemovals += 1;
|
|
||||||
memberPresent = false;
|
|
||||||
},
|
|
||||||
async attachTeamRepository(): Promise<void> {
|
|
||||||
repositoryPresent = true;
|
|
||||||
},
|
|
||||||
async detachTeamRepository(): Promise<void> {
|
|
||||||
repositoryDetachments += 1;
|
|
||||||
repositoryPresent = false;
|
|
||||||
},
|
|
||||||
async readTeamMember() {
|
|
||||||
return {
|
|
||||||
state: memberPresent ? 'present' : 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readTeamRepository() {
|
|
||||||
return {
|
|
||||||
state: repositoryPresent ? 'present' : 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const originalSeal = CredentialAuditJournal.prototype.seal;
|
|
||||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
|
||||||
this: CredentialAuditJournal,
|
|
||||||
outcome,
|
|
||||||
reasonCode,
|
|
||||||
): Promise<string> {
|
|
||||||
if (outcome === 'ok') {
|
|
||||||
throw new CredentialJournalError('journal-unavailable', 'injected final seal failure');
|
|
||||||
}
|
|
||||||
return originalSeal.call(this, outcome, reasonCode);
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
grantTeamRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
team: 'writers',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validation(),
|
|
||||||
{
|
|
||||||
stateRoot: join(cleanup, 'state'),
|
|
||||||
actor: 'provisioner',
|
|
||||||
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
|
||||||
// Simulate the next cooperating owner establishing the same state
|
|
||||||
// immediately after acquiring the released lock.
|
|
||||||
memberPresent = true;
|
|
||||||
repositoryPresent = true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).rejects.toMatchObject({ code: 'journal-unavailable', mutation: 'applied' });
|
|
||||||
expect(memberRemovals).toBe(0);
|
|
||||||
expect(repositoryDetachments).toBe(0);
|
|
||||||
expect(memberPresent).toBe(true);
|
|
||||||
expect(repositoryPresent).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never compensates successor state after lock release begins but release and audit sealing fail', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
||||||
let repositoryReads = 0;
|
|
||||||
let memberPresent = false;
|
|
||||||
let repositoryPresent = false;
|
|
||||||
let memberRemovals = 0;
|
|
||||||
let repositoryDetachments = 0;
|
|
||||||
const provider: GiteaTeamGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async resolveTeam() {
|
|
||||||
return {
|
|
||||||
id: 7,
|
|
||||||
name: 'writers',
|
|
||||||
permission: 'write',
|
|
||||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async listTeamRepositories() {
|
|
||||||
repositoryReads += 1;
|
|
||||||
return {
|
|
||||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async addTeamMember(): Promise<void> {
|
|
||||||
memberPresent = true;
|
|
||||||
},
|
|
||||||
async removeTeamMember(): Promise<void> {
|
|
||||||
memberRemovals += 1;
|
|
||||||
memberPresent = false;
|
|
||||||
},
|
|
||||||
async attachTeamRepository(): Promise<void> {
|
|
||||||
repositoryPresent = true;
|
|
||||||
},
|
|
||||||
async detachTeamRepository(): Promise<void> {
|
|
||||||
repositoryDetachments += 1;
|
|
||||||
repositoryPresent = false;
|
|
||||||
},
|
|
||||||
async readTeamMember() {
|
|
||||||
return {
|
|
||||||
state: memberPresent ? 'present' : 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readTeamRepository() {
|
|
||||||
return {
|
|
||||||
state: repositoryPresent ? 'present' : 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const originalSeal = CredentialAuditJournal.prototype.seal;
|
|
||||||
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
|
||||||
this: CredentialAuditJournal,
|
|
||||||
outcome,
|
|
||||||
reasonCode,
|
|
||||||
): Promise<string> {
|
|
||||||
if (reasonCode === 'mutation-lock-release-failed') {
|
|
||||||
throw new CredentialJournalError('journal-unavailable', 'injected release audit failure');
|
|
||||||
}
|
|
||||||
return originalSeal.call(this, outcome, reasonCode);
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
grantTeamRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
team: 'writers',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validation(),
|
|
||||||
{
|
|
||||||
stateRoot: join(cleanup, 'state'),
|
|
||||||
actor: 'provisioner',
|
|
||||||
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
|
||||||
memberPresent = true;
|
|
||||||
repositoryPresent = true;
|
|
||||||
throw new Error('injected release failure after successor mutation');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).rejects.toMatchObject({ code: 'journal-unavailable', mutation: 'applied' });
|
|
||||||
expect(memberRemovals).toBe(0);
|
|
||||||
expect(repositoryDetachments).toBe(0);
|
|
||||||
expect(memberPresent).toBe(true);
|
|
||||||
expect(repositoryPresent).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a shared team already attached to any repository outside the request', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
||||||
let mutated = false;
|
|
||||||
const provider: GiteaTeamGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async resolveTeam() {
|
|
||||||
return {
|
|
||||||
id: 7,
|
|
||||||
name: 'writers',
|
|
||||||
permission: 'write',
|
|
||||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async listTeamRepositories() {
|
|
||||||
return {
|
|
||||||
repositories: ['owner/unrelated'],
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async addTeamMember(): Promise<void> {
|
|
||||||
mutated = true;
|
|
||||||
},
|
|
||||||
async removeTeamMember(): Promise<void> {
|
|
||||||
mutated = true;
|
|
||||||
},
|
|
||||||
async attachTeamRepository(): Promise<void> {
|
|
||||||
mutated = true;
|
|
||||||
},
|
|
||||||
async detachTeamRepository(): Promise<void> {
|
|
||||||
mutated = true;
|
|
||||||
},
|
|
||||||
async readTeamMember() {
|
|
||||||
return {
|
|
||||||
state: 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readTeamRepository() {
|
|
||||||
return {
|
|
||||||
state: 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'absent',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await grantTeamRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
team: 'writers',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validation(),
|
|
||||||
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('refused');
|
|
||||||
expect(result.reason.code).toBe('team-scope-exceeds-request');
|
|
||||||
expect(mutated).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('journals absent team objects as absent rather than present', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
||||||
let repositoryReads = 0;
|
|
||||||
const provider: GiteaTeamGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async resolveTeam() {
|
|
||||||
return {
|
|
||||||
id: 7,
|
|
||||||
name: 'writers',
|
|
||||||
permission: 'write',
|
|
||||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async listTeamRepositories() {
|
|
||||||
repositoryReads += 1;
|
|
||||||
return {
|
|
||||||
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async addTeamMember(): Promise<void> {},
|
|
||||||
async removeTeamMember(): Promise<void> {},
|
|
||||||
async attachTeamRepository(): Promise<void> {},
|
|
||||||
async detachTeamRepository(): Promise<void> {},
|
|
||||||
async readTeamMember() {
|
|
||||||
return {
|
|
||||||
state: 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readTeamRepository() {
|
|
||||||
return {
|
|
||||||
state: 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const stateRoot = join(cleanup, 'state');
|
|
||||||
const result = await grantTeamRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
team: 'writers',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validation(),
|
|
||||||
{ stateRoot, actor: 'provisioner' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
|
||||||
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
|
||||||
expect(journal).toContain('team-member-absent');
|
|
||||||
expect(journal).toContain('team-repository-absent');
|
|
||||||
expect(journal).not.toContain('"decision":"team-member-present"');
|
|
||||||
expect(journal).not.toContain('"decision":"team-repository-present"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed and removes newly added membership when team scope changes during mutation', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
||||||
let repositoryReads = 0;
|
|
||||||
let membershipReads = 0;
|
|
||||||
let removed = false;
|
|
||||||
let detached = false;
|
|
||||||
const provider: GiteaTeamGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async resolveTeam() {
|
|
||||||
return {
|
|
||||||
id: 7,
|
|
||||||
name: 'writers',
|
|
||||||
permission: 'write',
|
|
||||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async listTeamRepositories() {
|
|
||||||
repositoryReads += 1;
|
|
||||||
return {
|
|
||||||
repositories: repositoryReads === 1 ? [] : ['owner/repo', 'owner/concurrent-attachment'],
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async addTeamMember(): Promise<void> {},
|
|
||||||
async removeTeamMember(): Promise<void> {
|
|
||||||
removed = true;
|
|
||||||
},
|
|
||||||
async attachTeamRepository(): Promise<void> {},
|
|
||||||
async detachTeamRepository(): Promise<void> {
|
|
||||||
detached = true;
|
|
||||||
},
|
|
||||||
async readTeamMember() {
|
|
||||||
membershipReads += 1;
|
|
||||||
return {
|
|
||||||
state: membershipReads === 1 || removed ? 'absent' : 'present',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readTeamRepository() {
|
|
||||||
return {
|
|
||||||
state: detached ? 'absent' : 'present',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const stateRoot = join(cleanup, 'state');
|
|
||||||
const result = await grantTeamRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
team: 'writers',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validation(),
|
|
||||||
{ stateRoot, actor: 'provisioner' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('team-scope-changed-during-grant');
|
|
||||||
expect(result.evidence.teamRepositorySet?.repositories).toEqual([
|
|
||||||
'owner/repo',
|
|
||||||
'owner/concurrent-attachment',
|
|
||||||
]);
|
|
||||||
expect(removed).toBe(true);
|
|
||||||
expect(detached).toBe(true);
|
|
||||||
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
|
||||||
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
|
||||||
expect(journal.indexOf('team-member-absent')).toBeLessThan(
|
|
||||||
journal.indexOf('team-member-applied'),
|
|
||||||
);
|
|
||||||
expect(journal).toContain('team-repository-rollback-applied');
|
|
||||||
expect(journal).toContain('team-repository-absent');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('compensates provider changes when repository attachment fails after applying', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
||||||
let memberPresent = false;
|
|
||||||
let repositoryPresent = false;
|
|
||||||
let memberRemoved = false;
|
|
||||||
let repositoryDetached = false;
|
|
||||||
const provider: GiteaTeamGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async resolveTeam() {
|
|
||||||
return {
|
|
||||||
id: 7,
|
|
||||||
name: 'writers',
|
|
||||||
permission: 'write',
|
|
||||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async listTeamRepositories() {
|
|
||||||
return {
|
|
||||||
repositories: repositoryPresent ? ['owner/repo'] : [],
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async addTeamMember(): Promise<void> {
|
|
||||||
memberPresent = true;
|
|
||||||
},
|
|
||||||
async removeTeamMember(): Promise<void> {
|
|
||||||
memberPresent = false;
|
|
||||||
memberRemoved = true;
|
|
||||||
},
|
|
||||||
async attachTeamRepository(): Promise<void> {
|
|
||||||
repositoryPresent = true;
|
|
||||||
throw new Error('provider response lost after attachment');
|
|
||||||
},
|
|
||||||
async detachTeamRepository(): Promise<void> {
|
|
||||||
repositoryPresent = false;
|
|
||||||
repositoryDetached = true;
|
|
||||||
},
|
|
||||||
async readTeamMember() {
|
|
||||||
return {
|
|
||||||
state: memberPresent ? 'present' : 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readTeamRepository() {
|
|
||||||
return {
|
|
||||||
state: repositoryPresent ? 'present' : 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await grantTeamRepositoryPermission(
|
|
||||||
{
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write',
|
|
||||||
team: 'writers',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
},
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validation(),
|
|
||||||
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(memberPresent).toBe(false);
|
|
||||||
expect(repositoryPresent).toBe(false);
|
|
||||||
expect(memberRemoved).toBe(true);
|
|
||||||
expect(repositoryDetached).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a second governed mutation while the same team lock is held', async (): Promise<void> => {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
||||||
let releaseFirst!: () => void;
|
|
||||||
const firstMayFinish = new Promise<void>((resolve): void => {
|
|
||||||
releaseFirst = resolve;
|
|
||||||
});
|
|
||||||
let markFirstEntered!: () => void;
|
|
||||||
const firstEntered = new Promise<void>((resolve): void => {
|
|
||||||
markFirstEntered = resolve;
|
|
||||||
});
|
|
||||||
let addCalls = 0;
|
|
||||||
let memberAdded = false;
|
|
||||||
let repositoryAttached = false;
|
|
||||||
const provider: GiteaTeamGrantProvider = {
|
|
||||||
async readBasicIdentity() {
|
|
||||||
return {
|
|
||||||
login: 'provisioner',
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async resolveTeam() {
|
|
||||||
return {
|
|
||||||
id: 7,
|
|
||||||
name: 'writers',
|
|
||||||
permission: 'write',
|
|
||||||
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async listTeamRepositories() {
|
|
||||||
return {
|
|
||||||
repositories: repositoryAttached ? ['owner/repo'] : [],
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async addTeamMember(): Promise<void> {
|
|
||||||
addCalls += 1;
|
|
||||||
markFirstEntered();
|
|
||||||
await firstMayFinish;
|
|
||||||
memberAdded = true;
|
|
||||||
},
|
|
||||||
async removeTeamMember(): Promise<void> {
|
|
||||||
memberAdded = false;
|
|
||||||
},
|
|
||||||
async attachTeamRepository(): Promise<void> {
|
|
||||||
repositoryAttached = true;
|
|
||||||
},
|
|
||||||
async detachTeamRepository(): Promise<void> {
|
|
||||||
repositoryAttached = false;
|
|
||||||
},
|
|
||||||
async readTeamMember() {
|
|
||||||
return {
|
|
||||||
state: memberAdded ? 'present' : 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readTeamRepository() {
|
|
||||||
return {
|
|
||||||
state: repositoryAttached ? 'present' : 'absent',
|
|
||||||
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readOrganizationMembership() {
|
|
||||||
return {
|
|
||||||
state: 'present',
|
|
||||||
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const request = {
|
|
||||||
identity: 'seat-name',
|
|
||||||
estate: 'homelab',
|
|
||||||
host: 'git.example.invalid',
|
|
||||||
repo: 'owner/repo',
|
|
||||||
permission: 'write' as const,
|
|
||||||
team: 'writers',
|
|
||||||
readOnlyControlIdentity: 'read-control',
|
|
||||||
};
|
|
||||||
const options = { stateRoot: join(cleanup, 'state-one'), actor: 'provisioner' };
|
|
||||||
const secondOptions = { stateRoot: join(cleanup, 'state-two'), actor: 'provisioner' };
|
|
||||||
|
|
||||||
const first = grantTeamRepositoryPermission(
|
|
||||||
request,
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validation(),
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
await firstEntered;
|
|
||||||
const second = await grantTeamRepositoryPermission(
|
|
||||||
request,
|
|
||||||
authority,
|
|
||||||
provider,
|
|
||||||
validation(),
|
|
||||||
secondOptions,
|
|
||||||
);
|
|
||||||
releaseFirst();
|
|
||||||
const completedFirst = await first;
|
|
||||||
|
|
||||||
expect(completedFirst.outcome).toBe('ok');
|
|
||||||
expect(second.outcome).toBe('indeterminate');
|
|
||||||
expect(second.reason.code).toBe('concurrent-mutation');
|
|
||||||
expect(second.mutation).toBe('none');
|
|
||||||
expect(addCalls).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,589 +0,0 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import { constants, lstatSync } from 'node:fs';
|
|
||||||
import { open } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { ensureManagedDirectory } from '../fleet/secure-file.js';
|
|
||||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
|
||||||
import type {
|
|
||||||
CredentialValidationDependencies,
|
|
||||||
ResolvedCredential,
|
|
||||||
} from './credential-provider.dto.js';
|
|
||||||
import type {
|
|
||||||
CredentialGrantResultDto,
|
|
||||||
DirectGrantRequestDto,
|
|
||||||
OrganizationMembershipEvidenceDto,
|
|
||||||
} from './grant.dto.js';
|
|
||||||
import type { RepositoryPermission } from './credential-result.dto.js';
|
|
||||||
import { CredentialGrantExecutionError } from './grant.js';
|
|
||||||
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
|
|
||||||
|
|
||||||
export interface TeamResolutionEvidence {
|
|
||||||
readonly id: number;
|
|
||||||
readonly name: string;
|
|
||||||
readonly permission: RepositoryPermission;
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
}
|
|
||||||
export interface PresenceEvidence {
|
|
||||||
readonly state: 'present' | 'absent';
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
}
|
|
||||||
export interface TeamRepositorySetEvidence {
|
|
||||||
readonly repositories: readonly string[];
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly contentType: string;
|
|
||||||
}
|
|
||||||
export interface TeamGrantRequest extends DirectGrantRequestDto {
|
|
||||||
readonly team: string;
|
|
||||||
}
|
|
||||||
export interface TeamGrantResult extends CredentialGrantResultDto {
|
|
||||||
readonly evidence: CredentialGrantResultDto['evidence'] & {
|
|
||||||
readonly team: TeamResolutionEvidence | null;
|
|
||||||
readonly teamMembership: PresenceEvidence | null;
|
|
||||||
readonly teamRepository: PresenceEvidence | null;
|
|
||||||
readonly teamRepositorySet: TeamRepositorySetEvidence | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export interface GiteaTeamGrantProvider {
|
|
||||||
readBasicIdentity(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string }>;
|
|
||||||
resolveTeam(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
organization: string,
|
|
||||||
team: string,
|
|
||||||
): Promise<TeamResolutionEvidence>;
|
|
||||||
listTeamRepositories(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
teamId: number,
|
|
||||||
): Promise<TeamRepositorySetEvidence>;
|
|
||||||
addTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
|
||||||
removeTeamMember(authority: ResolvedCredential, teamId: number, identity: string): Promise<void>;
|
|
||||||
attachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
|
||||||
detachTeamRepository(authority: ResolvedCredential, teamId: number, repo: string): Promise<void>;
|
|
||||||
readTeamMember(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
teamId: number,
|
|
||||||
identity: string,
|
|
||||||
): Promise<PresenceEvidence>;
|
|
||||||
readTeamRepository(
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
teamId: number,
|
|
||||||
repo: string,
|
|
||||||
): Promise<PresenceEvidence>;
|
|
||||||
readOrganizationMembership(
|
|
||||||
subject: ResolvedCredential,
|
|
||||||
organization: string,
|
|
||||||
): Promise<OrganizationMembershipEvidenceDto>;
|
|
||||||
}
|
|
||||||
type ReleaseTeamGrantLock = () => Promise<void>;
|
|
||||||
type AcquireTeamGrantLock = (
|
|
||||||
estate: string,
|
|
||||||
host: string,
|
|
||||||
teamId: number,
|
|
||||||
) => Promise<ReleaseTeamGrantLock>;
|
|
||||||
|
|
||||||
export interface TeamGrantOptions {
|
|
||||||
readonly stateRoot: string;
|
|
||||||
readonly actor: string;
|
|
||||||
readonly acquireTeamLock?: AcquireTeamGrantLock;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TeamGrantFinalization {
|
|
||||||
readonly outcome: 'ok' | 'refused' | 'indeterminate';
|
|
||||||
readonly reasonCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class TeamGrantLockError extends Error {
|
|
||||||
constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') {
|
|
||||||
super(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acquireTeamGrantLock(
|
|
||||||
estate: string,
|
|
||||||
host: string,
|
|
||||||
teamId: number,
|
|
||||||
): Promise<() => Promise<void>> {
|
|
||||||
const uid = process.getuid?.();
|
|
||||||
if (uid === undefined) throw new TeamGrantLockError('mutation-lock-unavailable');
|
|
||||||
const locksDirectory = `/tmp/mosaic-cred-team-locks-${uid.toString()}`;
|
|
||||||
ensureManagedDirectory(locksDirectory, locksDirectory);
|
|
||||||
const directory = lstatSync(locksDirectory);
|
|
||||||
if (
|
|
||||||
!directory.isDirectory() ||
|
|
||||||
directory.isSymbolicLink() ||
|
|
||||||
directory.uid !== uid ||
|
|
||||||
(directory.mode & 0o077) !== 0
|
|
||||||
) {
|
|
||||||
throw new TeamGrantLockError('mutation-lock-unavailable');
|
|
||||||
}
|
|
||||||
const lockPath = join(locksDirectory, `${estate}--${host}--team-${teamId.toString()}.lock`);
|
|
||||||
let handle: Awaited<ReturnType<typeof open>>;
|
|
||||||
try {
|
|
||||||
handle = await open(
|
|
||||||
lockPath,
|
|
||||||
constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,
|
|
||||||
0o600,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
throw new TeamGrantLockError('mutation-lock-unavailable');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const file = await handle.stat();
|
|
||||||
if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) {
|
|
||||||
throw new Error('team mutation lock file is unsafe');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
await handle.close().catch((): void => undefined);
|
|
||||||
throw new TeamGrantLockError('mutation-lock-unavailable');
|
|
||||||
}
|
|
||||||
// The child's fd 3 is a dup of the parent's open file description. Linux
|
|
||||||
// flock(2) associates the lock with that description, so it remains held
|
|
||||||
// after the helper exits until this process closes `handle` below.
|
|
||||||
const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], {
|
|
||||||
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
|
|
||||||
});
|
|
||||||
if (acquired.error !== undefined || acquired.status !== 0) {
|
|
||||||
await handle.close().catch((): void => undefined);
|
|
||||||
throw new TeamGrantLockError(
|
|
||||||
acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return async (): Promise<void> => {
|
|
||||||
await handle.close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function grantTeamRepositoryPermission(
|
|
||||||
request: TeamGrantRequest,
|
|
||||||
authority: ResolvedCredential,
|
|
||||||
provider: GiteaTeamGrantProvider,
|
|
||||||
dependencies: CredentialValidationDependencies,
|
|
||||||
options: TeamGrantOptions,
|
|
||||||
): Promise<TeamGrantResult> {
|
|
||||||
const journal = await CredentialAuditJournal.open(options.stateRoot, {
|
|
||||||
operation: 'grant',
|
|
||||||
actor: options.actor,
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
repo: request.repo,
|
|
||||||
});
|
|
||||||
await journal.recordIntent('provider-grant');
|
|
||||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
|
||||||
let releaseTeamLock: (() => Promise<void>) | undefined;
|
|
||||||
let teamLockReleaseStarted = false;
|
|
||||||
let rollbackTeam: TeamResolutionEvidence | undefined;
|
|
||||||
let membershipBeforeMutation: PresenceEvidence | undefined;
|
|
||||||
let repositoryAttachedBeforeMutation = false;
|
|
||||||
let memberMutationAttempted = false;
|
|
||||||
let repositoryMutationAttempted = false;
|
|
||||||
const sealFinalVerdict = async (
|
|
||||||
providerOutcome: 'ok' | 'refused' | 'indeterminate',
|
|
||||||
providerReasonCode: string,
|
|
||||||
): Promise<TeamGrantFinalization> => {
|
|
||||||
const release = releaseTeamLock;
|
|
||||||
releaseTeamLock = undefined;
|
|
||||||
if (release !== undefined) {
|
|
||||||
teamLockReleaseStarted = true;
|
|
||||||
try {
|
|
||||||
await release();
|
|
||||||
} catch {
|
|
||||||
await journal.seal('indeterminate', 'mutation-lock-release-failed');
|
|
||||||
return { outcome: 'indeterminate', reasonCode: 'mutation-lock-release-failed' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await journal.seal(providerOutcome, providerReasonCode);
|
|
||||||
return { outcome: providerOutcome, reasonCode: providerReasonCode };
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const authorityIdentity = await provider.readBasicIdentity(authority);
|
|
||||||
const organization = request.repo.split('/')[0] ?? '';
|
|
||||||
const team = await provider.resolveTeam(authority, organization, request.team);
|
|
||||||
rollbackTeam = team;
|
|
||||||
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {
|
|
||||||
await journal.seal('refused', 'provider-identity-mismatch');
|
|
||||||
return result(
|
|
||||||
request,
|
|
||||||
journal,
|
|
||||||
'refused',
|
|
||||||
'none',
|
|
||||||
'provider-identity-mismatch',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: authorityIdentity.endpoint,
|
|
||||||
contentType: authorityIdentity.contentType,
|
|
||||||
decision: 'identity-verified',
|
|
||||||
});
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: team.endpoint,
|
|
||||||
contentType: team.contentType,
|
|
||||||
decision: `permission-${team.permission}`,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
releaseTeamLock = await (options.acquireTeamLock ?? acquireTeamGrantLock)(
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
team.id,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!(error instanceof TeamGrantLockError)) throw error;
|
|
||||||
await journal.seal('indeterminate', error.code);
|
|
||||||
return result(
|
|
||||||
request,
|
|
||||||
journal,
|
|
||||||
'indeterminate',
|
|
||||||
'none',
|
|
||||||
error.code,
|
|
||||||
null,
|
|
||||||
team,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const teamRepositorySet = await provider.listTeamRepositories(authority, team.id);
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: teamRepositorySet.endpoint,
|
|
||||||
contentType: teamRepositorySet.contentType,
|
|
||||||
decision: 'team-repository-set-verified',
|
|
||||||
});
|
|
||||||
if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) {
|
|
||||||
const finalization = await sealFinalVerdict('refused', 'team-scope-exceeds-request');
|
|
||||||
return result(
|
|
||||||
request,
|
|
||||||
journal,
|
|
||||||
finalization.outcome,
|
|
||||||
'none',
|
|
||||||
finalization.reasonCode,
|
|
||||||
null,
|
|
||||||
team,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
teamRepositorySet,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const repositoryAttachedBefore = teamRepositorySet.repositories.includes(request.repo);
|
|
||||||
repositoryAttachedBeforeMutation = repositoryAttachedBefore;
|
|
||||||
const membershipBefore = await provider.readTeamMember(authority, team.id, request.identity);
|
|
||||||
membershipBeforeMutation = membershipBefore;
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: membershipBefore.endpoint,
|
|
||||||
contentType: membershipBefore.contentType,
|
|
||||||
decision: membershipBefore.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
|
||||||
});
|
|
||||||
mutation = 'unknown';
|
|
||||||
memberMutationAttempted = true;
|
|
||||||
await provider.addTeamMember(authority, team.id, request.identity);
|
|
||||||
mutation = 'applied';
|
|
||||||
await journal.recordMutation('team-member-applied');
|
|
||||||
repositoryMutationAttempted = true;
|
|
||||||
await provider.attachTeamRepository(authority, team.id, request.repo);
|
|
||||||
await journal.recordMutation('team-repository-applied');
|
|
||||||
let teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
|
||||||
let teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
|
||||||
const finalTeamRepositorySet = await provider.listTeamRepositories(authority, team.id);
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: teamMembership.endpoint,
|
|
||||||
contentType: teamMembership.contentType,
|
|
||||||
decision: teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
|
||||||
});
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: teamRepository.endpoint,
|
|
||||||
contentType: teamRepository.contentType,
|
|
||||||
decision:
|
|
||||||
teamRepository.state === 'present' ? 'team-repository-present' : 'team-repository-absent',
|
|
||||||
});
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: finalTeamRepositorySet.endpoint,
|
|
||||||
contentType: finalTeamRepositorySet.contentType,
|
|
||||||
decision: 'team-repository-set-verified',
|
|
||||||
});
|
|
||||||
const scopeRemainedExact =
|
|
||||||
finalTeamRepositorySet.repositories.length === 1 &&
|
|
||||||
finalTeamRepositorySet.repositories[0] === request.repo;
|
|
||||||
if (!scopeRemainedExact) {
|
|
||||||
if (membershipBefore.state === 'absent') {
|
|
||||||
await provider.removeTeamMember(authority, team.id, request.identity);
|
|
||||||
await journal.recordMutation('team-member-rollback-applied');
|
|
||||||
teamMembership = await provider.readTeamMember(authority, team.id, request.identity);
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: teamMembership.endpoint,
|
|
||||||
contentType: teamMembership.contentType,
|
|
||||||
decision:
|
|
||||||
teamMembership.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
|
||||||
});
|
|
||||||
if (teamMembership.state !== 'absent') throw new Error('team member rollback disagreed');
|
|
||||||
}
|
|
||||||
if (!repositoryAttachedBefore) {
|
|
||||||
await provider.detachTeamRepository(authority, team.id, request.repo);
|
|
||||||
await journal.recordMutation('team-repository-rollback-applied');
|
|
||||||
teamRepository = await provider.readTeamRepository(authority, team.id, request.repo);
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: teamRepository.endpoint,
|
|
||||||
contentType: teamRepository.contentType,
|
|
||||||
decision:
|
|
||||||
teamRepository.state === 'present'
|
|
||||||
? 'team-repository-present'
|
|
||||||
: 'team-repository-absent',
|
|
||||||
});
|
|
||||||
if (teamRepository.state !== 'absent') {
|
|
||||||
throw new Error('team repository rollback disagreed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const finalization = await sealFinalVerdict(
|
|
||||||
'indeterminate',
|
|
||||||
'team-scope-changed-during-grant',
|
|
||||||
);
|
|
||||||
return result(
|
|
||||||
request,
|
|
||||||
journal,
|
|
||||||
finalization.outcome,
|
|
||||||
'applied',
|
|
||||||
finalization.reasonCode,
|
|
||||||
null,
|
|
||||||
team,
|
|
||||||
teamMembership,
|
|
||||||
teamRepository,
|
|
||||||
null,
|
|
||||||
finalTeamRepositorySet,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const subject = await dependencies.resolver.resolve(
|
|
||||||
request.identity,
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
);
|
|
||||||
const organizationMembership =
|
|
||||||
subject === undefined
|
|
||||||
? null
|
|
||||||
: await provider.readOrganizationMembership(subject, organization);
|
|
||||||
const validation =
|
|
||||||
request.permission === 'read'
|
|
||||||
? await evaluateGiteaReadValidation(request, dependencies)
|
|
||||||
: await evaluateGiteaWriteValidation(request, dependencies);
|
|
||||||
if (organizationMembership !== null) {
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: organizationMembership.endpoint,
|
|
||||||
contentType: organizationMembership.contentType,
|
|
||||||
decision:
|
|
||||||
organizationMembership.state === 'present'
|
|
||||||
? 'organization-member-present'
|
|
||||||
: 'organization-member-absent',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (validation.evidence.providerIdentity !== null) {
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: validation.evidence.providerIdentity.endpoint,
|
|
||||||
contentType: validation.evidence.providerIdentity.contentType,
|
|
||||||
decision: 'identity-verified',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (validation.evidence.repositoryPermission !== null) {
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: validation.evidence.repositoryPermission.endpoint,
|
|
||||||
contentType: validation.evidence.repositoryPermission.contentType,
|
|
||||||
decision: `permission-${validation.evidence.repositoryPermission.effective}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (validation.evidence.writeDifferential !== null) {
|
|
||||||
await journal.recordMutation('transport-write-verified');
|
|
||||||
}
|
|
||||||
const ok =
|
|
||||||
teamMembership.state === 'present' &&
|
|
||||||
teamRepository.state === 'present' &&
|
|
||||||
organizationMembership?.state === 'present' &&
|
|
||||||
validation.outcome === 'ok' &&
|
|
||||||
validation.evidence.repositoryPermission?.effective === request.permission;
|
|
||||||
const finalization = await sealFinalVerdict(
|
|
||||||
ok ? 'ok' : 'indeterminate',
|
|
||||||
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
|
||||||
);
|
|
||||||
return result(
|
|
||||||
request,
|
|
||||||
journal,
|
|
||||||
finalization.outcome,
|
|
||||||
'applied',
|
|
||||||
finalization.reasonCode,
|
|
||||||
validation,
|
|
||||||
team,
|
|
||||||
teamMembership,
|
|
||||||
teamRepository,
|
|
||||||
organizationMembership,
|
|
||||||
finalTeamRepositorySet,
|
|
||||||
);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
let compensationError: unknown;
|
|
||||||
try {
|
|
||||||
if (
|
|
||||||
!teamLockReleaseStarted &&
|
|
||||||
rollbackTeam !== undefined &&
|
|
||||||
membershipBeforeMutation?.state === 'absent' &&
|
|
||||||
memberMutationAttempted
|
|
||||||
) {
|
|
||||||
let current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity);
|
|
||||||
if (current.state === 'present') {
|
|
||||||
await provider.removeTeamMember(authority, rollbackTeam.id, request.identity);
|
|
||||||
await journal.recordMutation('team-member-rollback-applied');
|
|
||||||
current = await provider.readTeamMember(authority, rollbackTeam.id, request.identity);
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: current.endpoint,
|
|
||||||
contentType: current.contentType,
|
|
||||||
decision: current.state === 'present' ? 'team-member-present' : 'team-member-absent',
|
|
||||||
});
|
|
||||||
if (current.state !== 'absent') throw new Error('team member rollback disagreed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!teamLockReleaseStarted &&
|
|
||||||
rollbackTeam !== undefined &&
|
|
||||||
!repositoryAttachedBeforeMutation &&
|
|
||||||
repositoryMutationAttempted
|
|
||||||
) {
|
|
||||||
let current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo);
|
|
||||||
if (current.state === 'present') {
|
|
||||||
await provider.detachTeamRepository(authority, rollbackTeam.id, request.repo);
|
|
||||||
await journal.recordMutation('team-repository-rollback-applied');
|
|
||||||
current = await provider.readTeamRepository(authority, rollbackTeam.id, request.repo);
|
|
||||||
await journal.recordProviderEvidence({
|
|
||||||
endpoint: current.endpoint,
|
|
||||||
contentType: current.contentType,
|
|
||||||
decision:
|
|
||||||
current.state === 'present' ? 'team-repository-present' : 'team-repository-absent',
|
|
||||||
});
|
|
||||||
if (current.state !== 'absent') throw new Error('team repository rollback disagreed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (rollbackError: unknown) {
|
|
||||||
compensationError = rollbackError;
|
|
||||||
}
|
|
||||||
const auditError =
|
|
||||||
compensationError instanceof CredentialJournalError
|
|
||||||
? compensationError
|
|
||||||
: error instanceof CredentialJournalError
|
|
||||||
? error
|
|
||||||
: undefined;
|
|
||||||
if (auditError !== undefined) {
|
|
||||||
throw new CredentialGrantExecutionError(auditError.code, mutation, journal.journalId());
|
|
||||||
}
|
|
||||||
const reasonCode =
|
|
||||||
compensationError !== undefined
|
|
||||||
? 'rollback-incomplete'
|
|
||||||
: mutation === 'applied'
|
|
||||||
? 'readback-missing'
|
|
||||||
: 'mutation-state-unknown';
|
|
||||||
let finalization: Awaited<ReturnType<typeof sealFinalVerdict>>;
|
|
||||||
try {
|
|
||||||
finalization = await sealFinalVerdict('indeterminate', reasonCode);
|
|
||||||
} catch (journalError: unknown) {
|
|
||||||
if (journalError instanceof CredentialJournalError) {
|
|
||||||
throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId());
|
|
||||||
}
|
|
||||||
throw journalError;
|
|
||||||
}
|
|
||||||
return result(
|
|
||||||
request,
|
|
||||||
journal,
|
|
||||||
finalization.outcome,
|
|
||||||
mutation,
|
|
||||||
finalization.reasonCode,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
const release = releaseTeamLock;
|
|
||||||
releaseTeamLock = undefined;
|
|
||||||
if (release !== undefined) {
|
|
||||||
teamLockReleaseStarted = true;
|
|
||||||
try {
|
|
||||||
await release();
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
await journal.seal('indeterminate', 'mutation-lock-release-failed');
|
|
||||||
} catch (journalError: unknown) {
|
|
||||||
if (journalError instanceof CredentialJournalError) {
|
|
||||||
throw new CredentialGrantExecutionError(
|
|
||||||
journalError.code,
|
|
||||||
mutation,
|
|
||||||
journal.journalId(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw journalError;
|
|
||||||
}
|
|
||||||
throw new CredentialGrantExecutionError(
|
|
||||||
'mutation-lock-release-failed',
|
|
||||||
mutation,
|
|
||||||
journal.journalId(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function result(
|
|
||||||
request: TeamGrantRequest,
|
|
||||||
journal: CredentialAuditJournal,
|
|
||||||
outcome: 'ok' | 'refused' | 'indeterminate',
|
|
||||||
mutation: 'none' | 'unknown' | 'applied',
|
|
||||||
code: string,
|
|
||||||
validation: Awaited<ReturnType<typeof evaluateGiteaWriteValidation>> | null,
|
|
||||||
team: TeamResolutionEvidence | null,
|
|
||||||
teamMembership: PresenceEvidence | null,
|
|
||||||
teamRepository: PresenceEvidence | null,
|
|
||||||
organizationMembership: OrganizationMembershipEvidenceDto | null,
|
|
||||||
teamRepositorySet: TeamRepositorySetEvidence | null,
|
|
||||||
): TeamGrantResult {
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
operation: 'grant',
|
|
||||||
outcome,
|
|
||||||
exitCode: outcome === 'ok' ? 0 : outcome === 'refused' ? 10 : 30,
|
|
||||||
retryable: false,
|
|
||||||
subject: {
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
repo: request.repo,
|
|
||||||
},
|
|
||||||
mutation,
|
|
||||||
reason: {
|
|
||||||
code,
|
|
||||||
message:
|
|
||||||
outcome === 'ok'
|
|
||||||
? 'Team grant matched every provider read-back.'
|
|
||||||
: 'Team grant was refused or could not be established.',
|
|
||||||
},
|
|
||||||
evidence: {
|
|
||||||
providerIdentity: validation?.evidence.providerIdentity ?? null,
|
|
||||||
tokenCapabilities: validation?.evidence.tokenCapabilities ?? {
|
|
||||||
state: 'not-measured',
|
|
||||||
scopes: [],
|
|
||||||
source: 'runtime-not-authorized',
|
|
||||||
},
|
|
||||||
repositoryPermission: validation?.evidence.repositoryPermission ?? null,
|
|
||||||
writeDifferential: validation?.evidence.writeDifferential ?? null,
|
|
||||||
collaboratorPermission: null,
|
|
||||||
organizationMembership,
|
|
||||||
team,
|
|
||||||
teamMembership,
|
|
||||||
teamRepository,
|
|
||||||
teamRepositorySet,
|
|
||||||
},
|
|
||||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { CredentialProviderEvidenceError } from './gitea-provider.js';
|
|
||||||
import {
|
|
||||||
evaluateGiteaReadValidation,
|
|
||||||
evaluateGiteaWriteValidation,
|
|
||||||
type CredentialResolver,
|
|
||||||
type CredentialValidationDependencies,
|
|
||||||
type GiteaCredentialProvider,
|
|
||||||
type ProviderIdentityEvidence,
|
|
||||||
type ReceivePackEvidence,
|
|
||||||
type RepositoryPermissionEvidence,
|
|
||||||
type ResolvedCredential,
|
|
||||||
} from './validate.js';
|
|
||||||
|
|
||||||
interface FixtureOptions {
|
|
||||||
readonly subjectProviderIdentity?: string;
|
|
||||||
readonly subjectPermission?: 'none' | 'read' | 'write' | 'admin';
|
|
||||||
readonly subjectTransportState?: 'advertised' | 'refused';
|
|
||||||
readonly subjectTransportPrincipal?: string;
|
|
||||||
readonly subjectTransportResolutionId?: string;
|
|
||||||
readonly controlProviderIdentity?: string;
|
|
||||||
readonly controlPermission?: 'none' | 'read' | 'write' | 'admin';
|
|
||||||
readonly controlTransportState?: 'advertised' | 'refused';
|
|
||||||
readonly controlTransportPrincipal?: string;
|
|
||||||
readonly unauthenticatedTransportState?: 'advertised' | 'refused';
|
|
||||||
readonly omitControl?: boolean;
|
|
||||||
readonly requiredPermission?: 'read' | 'write' | 'admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Fixture {
|
|
||||||
readonly dependencies: CredentialValidationDependencies;
|
|
||||||
readonly resolverCalls: string[];
|
|
||||||
readonly identityHandles: ResolvedCredential[];
|
|
||||||
readonly permissionHandles: ResolvedCredential[];
|
|
||||||
readonly receivePackHandles: Array<ResolvedCredential | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUBJECT = 'seat-name';
|
|
||||||
const CONTROL = 'read-only-control';
|
|
||||||
const ESTATE = 'homelab';
|
|
||||||
const HOST = 'git.example.invalid';
|
|
||||||
const REPO = 'owner/repo';
|
|
||||||
|
|
||||||
function credential(identity: string, resolutionId: string): ResolvedCredential {
|
|
||||||
return Object.freeze({
|
|
||||||
identity,
|
|
||||||
estate: ESTATE,
|
|
||||||
host: HOST,
|
|
||||||
resolutionId,
|
|
||||||
secret: new Uint8Array([99, 97, 110, 97, 114, 121]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function fixture(options: FixtureOptions = {}): Fixture {
|
|
||||||
const subjectCredential = credential(SUBJECT, 'subject-resolution');
|
|
||||||
const controlCredential = credential(CONTROL, 'control-resolution');
|
|
||||||
const resolverCalls: string[] = [];
|
|
||||||
const identityHandles: ResolvedCredential[] = [];
|
|
||||||
const permissionHandles: ResolvedCredential[] = [];
|
|
||||||
const receivePackHandles: Array<ResolvedCredential | undefined> = [];
|
|
||||||
|
|
||||||
const resolver: CredentialResolver = {
|
|
||||||
async resolve(identity: string): Promise<ResolvedCredential | undefined> {
|
|
||||||
resolverCalls.push(identity);
|
|
||||||
if (identity === SUBJECT) return subjectCredential;
|
|
||||||
if (identity === CONTROL && options.omitControl !== true) return controlCredential;
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const provider: GiteaCredentialProvider = {
|
|
||||||
async readIdentity(resolved: ResolvedCredential): Promise<ProviderIdentityEvidence> {
|
|
||||||
identityHandles.push(resolved);
|
|
||||||
const login =
|
|
||||||
resolved.identity === SUBJECT
|
|
||||||
? (options.subjectProviderIdentity ?? SUBJECT)
|
|
||||||
: (options.controlProviderIdentity ?? CONTROL);
|
|
||||||
return {
|
|
||||||
login,
|
|
||||||
endpoint: 'GET /api/v1/user',
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async readRepositoryPermission(
|
|
||||||
resolved: ResolvedCredential,
|
|
||||||
): Promise<RepositoryPermissionEvidence> {
|
|
||||||
permissionHandles.push(resolved);
|
|
||||||
const effective =
|
|
||||||
resolved.identity === SUBJECT
|
|
||||||
? (options.subjectPermission ?? 'write')
|
|
||||||
: (options.controlPermission ?? 'read');
|
|
||||||
return {
|
|
||||||
effective,
|
|
||||||
endpoint: `GET /api/v1/repos/${REPO}`,
|
|
||||||
contentType: 'application/json',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async probeReceivePack(resolved: ResolvedCredential | undefined): Promise<ReceivePackEvidence> {
|
|
||||||
receivePackHandles.push(resolved);
|
|
||||||
if (resolved === undefined) {
|
|
||||||
return {
|
|
||||||
state: options.unauthenticatedTransportState ?? 'refused',
|
|
||||||
principal: null,
|
|
||||||
resolutionId: null,
|
|
||||||
contentType: 'text/plain',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (resolved.identity === SUBJECT) {
|
|
||||||
return {
|
|
||||||
state: options.subjectTransportState ?? 'advertised',
|
|
||||||
principal: options.subjectTransportPrincipal ?? SUBJECT,
|
|
||||||
resolutionId: options.subjectTransportResolutionId ?? resolved.resolutionId,
|
|
||||||
contentType: 'application/x-git-receive-pack-advertisement',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
state: options.controlTransportState ?? 'refused',
|
|
||||||
principal: options.controlTransportPrincipal ?? CONTROL,
|
|
||||||
resolutionId: resolved.resolutionId,
|
|
||||||
contentType: 'text/plain',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
dependencies: {
|
|
||||||
resolver,
|
|
||||||
provider,
|
|
||||||
estateRegistry: {
|
|
||||||
matches(estate: string, host: string): boolean {
|
|
||||||
return estate === ESTATE && host === HOST;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resolverCalls,
|
|
||||||
identityHandles,
|
|
||||||
permissionHandles,
|
|
||||||
receivePackHandles,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validate(options: FixtureOptions = {}): Promise<{
|
|
||||||
readonly result: Awaited<ReturnType<typeof evaluateGiteaWriteValidation>>;
|
|
||||||
readonly observed: Fixture;
|
|
||||||
}> {
|
|
||||||
const observed = fixture(options);
|
|
||||||
const result = await evaluateGiteaWriteValidation(
|
|
||||||
{
|
|
||||||
identity: SUBJECT,
|
|
||||||
estate: ESTATE,
|
|
||||||
host: HOST,
|
|
||||||
repo: REPO,
|
|
||||||
readOnlyControlIdentity: CONTROL,
|
|
||||||
requiredPermission: options.requiredPermission,
|
|
||||||
},
|
|
||||||
observed.dependencies,
|
|
||||||
);
|
|
||||||
return { result, observed };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Gitea read validation', (): void => {
|
|
||||||
it('reads the explicit provider identity and repository permission without a write control', async (): Promise<void> => {
|
|
||||||
const observed = fixture({ subjectPermission: 'read' });
|
|
||||||
const result = await evaluateGiteaReadValidation(
|
|
||||||
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
|
|
||||||
observed.dependencies,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('ok');
|
|
||||||
expect(result.evidence.providerIdentity?.login).toBe(SUBJECT);
|
|
||||||
expect(result.evidence.repositoryPermission?.effective).toBe('read');
|
|
||||||
expect(result.evidence.writeDifferential).toBeNull();
|
|
||||||
expect(observed.resolverCalls).toEqual([SUBJECT]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a repository object whose permission flags establish no read access', async (): Promise<void> => {
|
|
||||||
const observed = fixture({ subjectPermission: 'none' });
|
|
||||||
const result = await evaluateGiteaReadValidation(
|
|
||||||
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
|
|
||||||
observed.dependencies,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('refused');
|
|
||||||
expect(result.reason.code).toBe('permission-denied');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies the provider rejecting the subject credential as an authoritative refusal', async (): Promise<void> => {
|
|
||||||
const observed = fixture({ subjectPermission: 'read' });
|
|
||||||
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'credential-rejected',
|
|
||||||
'provider rejected the supplied credential',
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const result = await evaluateGiteaReadValidation(
|
|
||||||
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
|
|
||||||
observed.dependencies,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('refused');
|
|
||||||
expect(result.exitCode).toBe(10);
|
|
||||||
expect(result.reason.code).toBe('credential-rejected');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('confirms in-scope capability while reporting identity as not measured', async (): Promise<void> => {
|
|
||||||
const observed = fixture({ subjectPermission: 'write' });
|
|
||||||
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'identity-read-forbidden',
|
|
||||||
'identity endpoint requires a scope this token does not hold',
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const result = await evaluateGiteaReadValidation(
|
|
||||||
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
|
|
||||||
observed.dependencies,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('identity-not-measured');
|
|
||||||
expect(result.evidence.providerIdentity).toBeNull();
|
|
||||||
expect(result.evidence.repositoryPermission?.effective).toBe('write');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a shared fallback rather than reporting a different principal as the subject', async (): Promise<void> => {
|
|
||||||
const observed = fixture({
|
|
||||||
subjectProviderIdentity: 'shared-owner',
|
|
||||||
subjectPermission: 'read',
|
|
||||||
});
|
|
||||||
const result = await evaluateGiteaReadValidation(
|
|
||||||
{ identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO },
|
|
||||||
observed.dependencies,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('refused');
|
|
||||||
expect(result.reason.code).toBe('provider-identity-mismatch');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('principal-bound Gitea write validation contract v1.1', (): void => {
|
|
||||||
it('confirms write capability when identity is scope-forbidden without exposing the internal reason', async (): Promise<void> => {
|
|
||||||
const observed = fixture();
|
|
||||||
const readIdentity = observed.dependencies.provider.readIdentity.bind(
|
|
||||||
observed.dependencies.provider,
|
|
||||||
);
|
|
||||||
observed.dependencies.provider.readIdentity = async (
|
|
||||||
resolved,
|
|
||||||
): Promise<ProviderIdentityEvidence> => {
|
|
||||||
if (resolved.identity === SUBJECT) {
|
|
||||||
throw new CredentialProviderEvidenceError(
|
|
||||||
'identity-read-forbidden',
|
|
||||||
'identity endpoint scope forbidden',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return readIdentity(resolved);
|
|
||||||
};
|
|
||||||
const result = await evaluateGiteaWriteValidation(
|
|
||||||
{
|
|
||||||
identity: SUBJECT,
|
|
||||||
estate: ESTATE,
|
|
||||||
host: HOST,
|
|
||||||
repo: REPO,
|
|
||||||
readOnlyControlIdentity: CONTROL,
|
|
||||||
},
|
|
||||||
observed.dependencies,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('identity-not-measured');
|
|
||||||
expect(result.evidence.repositoryPermission?.effective).toBe('write');
|
|
||||||
expect(observed.receivePackHandles).toEqual([
|
|
||||||
expect.objectContaining({ identity: SUBJECT }),
|
|
||||||
expect.objectContaining({ identity: CONTROL }),
|
|
||||||
undefined,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise<void> => {
|
|
||||||
const { result, observed } = await validate();
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('ok');
|
|
||||||
expect(observed.resolverCalls).toEqual([SUBJECT, CONTROL]);
|
|
||||||
expect(observed.identityHandles[0]).toBe(observed.permissionHandles[0]);
|
|
||||||
expect(observed.identityHandles[0]).toBe(observed.receivePackHandles[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a subject credential whose provider identity is a shared fallback', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({ subjectProviderIdentity: 'shared-owner' });
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('refused');
|
|
||||||
expect(result.reason.code).toBe('provider-identity-mismatch');
|
|
||||||
expect(result.mutation).toBe('none');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('routes a transport principal mismatch to indeterminate, not refused', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({ subjectTransportPrincipal: 'shared-owner' });
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('transport-principal-mismatch');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('routes a transport credential-handle mismatch to indeterminate', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({ subjectTransportResolutionId: 'fallback-resolution' });
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('transport-principal-mismatch');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses when the provider repository object authoritatively denies write', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({ subjectPermission: 'read' });
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('refused');
|
|
||||||
expect(result.reason.code).toBe('permission-denied');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is indeterminate when repo permission says write but receive-pack refuses', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({ subjectTransportState: 'refused' });
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('permission-evidence-disagrees');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses write permission when admin permission is explicitly required', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({
|
|
||||||
requiredPermission: 'admin',
|
|
||||||
subjectPermission: 'write',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('refused');
|
|
||||||
expect(result.reason.code).toBe('permission-denied');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts admin permission when admin is explicitly required', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({
|
|
||||||
requiredPermission: 'admin',
|
|
||||||
subjectPermission: 'admin',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('makes a write-capable read-only control invalidate the entire result', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({ controlPermission: 'write' });
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('read-only-control-invalid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('makes an identity-mismatched read-only control invalidate the entire result', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({ controlProviderIdentity: 'other-control' });
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('read-only-control-invalid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('makes a read-only control that receives write transport invalidate the result', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({ controlTransportState: 'advertised' });
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('read-only-control-invalid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is indeterminate when the configured read-only control credential is absent', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({ omitControl: true });
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('read-only-control-invalid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the unauthenticated arm and rejects an advertisement there', async (): Promise<void> => {
|
|
||||||
const { result } = await validate({ unauthenticatedTransportState: 'advertised' });
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('indeterminate');
|
|
||||||
expect(result.reason.code).toBe('permission-evidence-disagrees');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses an estate-host mismatch before resolving any credential', async (): Promise<void> => {
|
|
||||||
const observed = fixture();
|
|
||||||
const result = await evaluateGiteaWriteValidation(
|
|
||||||
{
|
|
||||||
identity: SUBJECT,
|
|
||||||
estate: 'usc',
|
|
||||||
host: HOST,
|
|
||||||
repo: REPO,
|
|
||||||
readOnlyControlIdentity: CONTROL,
|
|
||||||
},
|
|
||||||
observed.dependencies,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('refused');
|
|
||||||
expect(result.reason.code).toBe('estate-host-mismatch');
|
|
||||||
expect(observed.resolverCalls).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns structured proof bounds only after every principal-bound arm passes', async (): Promise<void> => {
|
|
||||||
const { result } = await validate();
|
|
||||||
|
|
||||||
expect(result.outcome).toBe('ok');
|
|
||||||
expect(result.evidence.writeDifferential).toMatchObject({
|
|
||||||
state: 'can-write',
|
|
||||||
credentialBinding: 'same-resolution',
|
|
||||||
transportPrincipal: SUBJECT,
|
|
||||||
authenticatedReceivePack: 'advertised',
|
|
||||||
readOnlyControl: {
|
|
||||||
identity: CONTROL,
|
|
||||||
providerPermission: 'read',
|
|
||||||
receivePack: 'refused',
|
|
||||||
},
|
|
||||||
unauthenticatedReceivePack: 'refused',
|
|
||||||
artifactCreated: false,
|
|
||||||
});
|
|
||||||
expect(result.evidence.writeDifferential?.proves).toContain('declared subject credential');
|
|
||||||
expect(result.evidence.writeDifferential?.doesNotProve).toContain('branch protection');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,521 +0,0 @@
|
|||||||
import { CredentialProviderEvidenceError } from './gitea-provider.js';
|
|
||||||
import type {
|
|
||||||
CredentialValidationDependencies,
|
|
||||||
GiteaReadValidationRequestDto,
|
|
||||||
GiteaWriteValidationRequestDto,
|
|
||||||
ResolvedCredential,
|
|
||||||
} from './credential-provider.dto.js';
|
|
||||||
import type {
|
|
||||||
CredentialOutcome,
|
|
||||||
CredentialReasonDto,
|
|
||||||
CredentialValidationEvidenceDto,
|
|
||||||
CredentialValidationResultDto,
|
|
||||||
ProviderIdentityEvidenceDto,
|
|
||||||
ReceivePackEvidenceDto,
|
|
||||||
RepositoryPermissionEvidenceDto,
|
|
||||||
WriteDifferentialEvidenceDto,
|
|
||||||
} from './credential-result.dto.js';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
CredentialResolver,
|
|
||||||
CredentialValidationDependencies,
|
|
||||||
GiteaCredentialProvider,
|
|
||||||
GiteaReadValidationRequestDto,
|
|
||||||
GiteaWriteValidationRequestDto,
|
|
||||||
ResolvedCredential,
|
|
||||||
} from './credential-provider.dto.js';
|
|
||||||
export type {
|
|
||||||
ProviderIdentityEvidenceDto as ProviderIdentityEvidence,
|
|
||||||
ReceivePackEvidenceDto as ReceivePackEvidence,
|
|
||||||
RepositoryPermissionEvidenceDto as RepositoryPermissionEvidence,
|
|
||||||
} from './credential-result.dto.js';
|
|
||||||
|
|
||||||
const JSON_CONTENT_TYPE = 'application/json';
|
|
||||||
const RUNTIME_SCOPE_NOT_MEASURED = {
|
|
||||||
state: 'not-measured' as const,
|
|
||||||
scopes: [] as readonly string[],
|
|
||||||
source: 'runtime-not-authorized' as const,
|
|
||||||
};
|
|
||||||
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
|
|
||||||
|
|
||||||
interface ResultOptions {
|
|
||||||
readonly outcome: CredentialOutcome;
|
|
||||||
readonly code: string;
|
|
||||||
readonly message: string;
|
|
||||||
readonly retryable?: boolean;
|
|
||||||
readonly evidence?: CredentialValidationEvidenceDto;
|
|
||||||
}
|
|
||||||
|
|
||||||
function subject(request: GiteaReadValidationRequestDto): CredentialValidationResultDto['subject'] {
|
|
||||||
return {
|
|
||||||
identity: request.identity,
|
|
||||||
estate: request.estate,
|
|
||||||
host: request.host,
|
|
||||||
repo: request.repo,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function result(
|
|
||||||
request: GiteaReadValidationRequestDto,
|
|
||||||
options: ResultOptions,
|
|
||||||
): CredentialValidationResultDto {
|
|
||||||
const exits: Readonly<Record<CredentialOutcome, 0 | 10 | 20 | 30>> = {
|
|
||||||
ok: 0,
|
|
||||||
refused: 10,
|
|
||||||
error: 20,
|
|
||||||
indeterminate: 30,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
operation: 'validate',
|
|
||||||
outcome: options.outcome,
|
|
||||||
exitCode: exits[options.outcome],
|
|
||||||
retryable: options.retryable ?? false,
|
|
||||||
subject: subject(request),
|
|
||||||
mutation: 'none',
|
|
||||||
reason: { code: options.code, message: options.message },
|
|
||||||
evidence: options.evidence ?? {
|
|
||||||
providerIdentity: null,
|
|
||||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
|
||||||
repositoryPermission: null,
|
|
||||||
writeDifferential: null,
|
|
||||||
},
|
|
||||||
audit: { journalId: null, state: 'not-started' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function refused(
|
|
||||||
request: GiteaReadValidationRequestDto,
|
|
||||||
reason: CredentialReasonDto,
|
|
||||||
evidence?: CredentialValidationEvidenceDto,
|
|
||||||
): CredentialValidationResultDto {
|
|
||||||
return result(request, {
|
|
||||||
outcome: 'refused',
|
|
||||||
code: reason.code,
|
|
||||||
message: reason.message,
|
|
||||||
...(evidence === undefined ? {} : { evidence }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function indeterminate(
|
|
||||||
request: GiteaReadValidationRequestDto,
|
|
||||||
reason: CredentialReasonDto,
|
|
||||||
evidence?: CredentialValidationEvidenceDto,
|
|
||||||
): CredentialValidationResultDto {
|
|
||||||
return result(request, {
|
|
||||||
outcome: 'indeterminate',
|
|
||||||
code: reason.code,
|
|
||||||
message: reason.message,
|
|
||||||
...(evidence === undefined ? {} : { evidence }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function providerEvidenceFailure(
|
|
||||||
request: GiteaReadValidationRequestDto,
|
|
||||||
error: CredentialProviderEvidenceError,
|
|
||||||
): CredentialValidationResultDto {
|
|
||||||
if (error.code === 'credential-rejected') {
|
|
||||||
return refused(request, {
|
|
||||||
code: error.code,
|
|
||||||
message: 'The provider authoritatively rejected the supplied subject credential.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return indeterminate(request, {
|
|
||||||
code: error.code,
|
|
||||||
message: 'Provider evidence could not be evaluated completely.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function identityContentTypeValid(evidence: ProviderIdentityEvidenceDto): boolean {
|
|
||||||
return evidence.contentType.toLowerCase().startsWith(JSON_CONTENT_TYPE);
|
|
||||||
}
|
|
||||||
|
|
||||||
function permissionContentTypeValid(evidence: RepositoryPermissionEvidenceDto): boolean {
|
|
||||||
return evidence.contentType.toLowerCase().startsWith(JSON_CONTENT_TYPE);
|
|
||||||
}
|
|
||||||
|
|
||||||
function advertised(evidence: ReceivePackEvidenceDto): boolean {
|
|
||||||
return (
|
|
||||||
evidence.state === 'advertised' &&
|
|
||||||
evidence.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveCredential(
|
|
||||||
request: GiteaWriteValidationRequestDto,
|
|
||||||
identity: string,
|
|
||||||
dependencies: CredentialValidationDependencies,
|
|
||||||
): Promise<ResolvedCredential | undefined> {
|
|
||||||
return dependencies.resolver.resolve(identity, request.estate, request.host);
|
|
||||||
}
|
|
||||||
|
|
||||||
function successfulEvidence(
|
|
||||||
subjectLogin: string,
|
|
||||||
subjectIdentity: ProviderIdentityEvidenceDto | null,
|
|
||||||
subjectPermission: RepositoryPermissionEvidenceDto,
|
|
||||||
subjectReceivePack: ReceivePackEvidenceDto,
|
|
||||||
controlIdentity: ProviderIdentityEvidenceDto,
|
|
||||||
controlPermission: RepositoryPermissionEvidenceDto,
|
|
||||||
controlReceivePack: ReceivePackEvidenceDto,
|
|
||||||
): CredentialValidationEvidenceDto {
|
|
||||||
const writeDifferential: WriteDifferentialEvidenceDto = {
|
|
||||||
state: 'can-write',
|
|
||||||
credentialBinding: 'same-resolution',
|
|
||||||
transportPrincipal: subjectLogin,
|
|
||||||
authenticatedReceivePack: 'advertised',
|
|
||||||
readOnlyControl: {
|
|
||||||
identity: controlIdentity.login,
|
|
||||||
providerPermission: controlPermission.effective,
|
|
||||||
receivePack: controlReceivePack.state,
|
|
||||||
},
|
|
||||||
unauthenticatedReceivePack: 'refused',
|
|
||||||
artifactCreated: false,
|
|
||||||
proves:
|
|
||||||
'The declared subject credential authenticated provider identity, repository permission, and write transport while a distinct provider-confirmed read-only principal and an unauthenticated caller were refused.',
|
|
||||||
doesNotProve:
|
|
||||||
'A particular ref update will pass branch protection, hooks, races, or content policy.',
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
providerIdentity: subjectIdentity,
|
|
||||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
|
||||||
repositoryPermission: subjectPermission,
|
|
||||||
writeDifferential,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function evaluateGiteaReadValidationUnsafe(
|
|
||||||
request: GiteaReadValidationRequestDto,
|
|
||||||
dependencies: CredentialValidationDependencies,
|
|
||||||
): Promise<CredentialValidationResultDto> {
|
|
||||||
if (!dependencies.estateRegistry.matches(request.estate, request.host)) {
|
|
||||||
return refused(request, {
|
|
||||||
code: 'estate-host-mismatch',
|
|
||||||
message: 'The declared estate does not contain the declared host.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const resolved = await dependencies.resolver.resolve(
|
|
||||||
request.identity,
|
|
||||||
request.estate,
|
|
||||||
request.host,
|
|
||||||
);
|
|
||||||
if (resolved === undefined) {
|
|
||||||
return refused(request, {
|
|
||||||
code: 'no-token-for-identity',
|
|
||||||
message: 'The explicit identity has no credential in the declared estate.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let providerIdentity: ProviderIdentityEvidenceDto | null;
|
|
||||||
try {
|
|
||||||
providerIdentity = await dependencies.provider.readIdentity(resolved);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (
|
|
||||||
error instanceof CredentialProviderEvidenceError &&
|
|
||||||
error.code === 'identity-read-forbidden'
|
|
||||||
) {
|
|
||||||
const repositoryPermission = await dependencies.provider.readRepositoryPermission(
|
|
||||||
resolved,
|
|
||||||
request.repo,
|
|
||||||
);
|
|
||||||
const evidence: CredentialValidationEvidenceDto = {
|
|
||||||
providerIdentity: null,
|
|
||||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
|
||||||
repositoryPermission,
|
|
||||||
writeDifferential: null,
|
|
||||||
};
|
|
||||||
if (!permissionContentTypeValid(repositoryPermission)) {
|
|
||||||
return indeterminate(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'unexpected-content-type',
|
|
||||||
message: 'In-scope capability evidence was not JSON.',
|
|
||||||
},
|
|
||||||
evidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (repositoryPermission.effective === 'none') {
|
|
||||||
return refused(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'permission-denied',
|
|
||||||
message: 'The in-scope provider object denies repository access.',
|
|
||||||
},
|
|
||||||
evidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return indeterminate(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'identity-not-measured',
|
|
||||||
message:
|
|
||||||
'Repository capability was confirmed, but identity was not measured because this least-privilege token cannot read /user.',
|
|
||||||
},
|
|
||||||
evidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const repositoryPermission = await dependencies.provider.readRepositoryPermission(
|
|
||||||
resolved,
|
|
||||||
request.repo,
|
|
||||||
);
|
|
||||||
const evidence: CredentialValidationEvidenceDto = {
|
|
||||||
providerIdentity,
|
|
||||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
|
||||||
repositoryPermission,
|
|
||||||
writeDifferential: null,
|
|
||||||
};
|
|
||||||
if (
|
|
||||||
!identityContentTypeValid(providerIdentity) ||
|
|
||||||
!permissionContentTypeValid(repositoryPermission)
|
|
||||||
) {
|
|
||||||
return indeterminate(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'unexpected-content-type',
|
|
||||||
message: 'Provider read evidence was not JSON.',
|
|
||||||
},
|
|
||||||
evidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (repositoryPermission.effective === 'none') {
|
|
||||||
return refused(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'permission-denied',
|
|
||||||
message: 'The provider repository object denies read permission.',
|
|
||||||
},
|
|
||||||
evidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (providerIdentity.login !== request.identity) {
|
|
||||||
return refused(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'provider-identity-mismatch',
|
|
||||||
message: 'The provider credential identity does not equal the declared subject.',
|
|
||||||
},
|
|
||||||
evidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result(request, {
|
|
||||||
outcome: 'ok',
|
|
||||||
code: 'validation-verified',
|
|
||||||
message: 'Provider identity and repository permission were read back.',
|
|
||||||
evidence,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function evaluateGiteaReadValidation(
|
|
||||||
request: GiteaReadValidationRequestDto,
|
|
||||||
dependencies: CredentialValidationDependencies,
|
|
||||||
): Promise<CredentialValidationResultDto> {
|
|
||||||
try {
|
|
||||||
return await evaluateGiteaReadValidationUnsafe(request, dependencies);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof CredentialProviderEvidenceError) {
|
|
||||||
return providerEvidenceFailure(request, error);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function evaluateGiteaWriteValidationUnsafe(
|
|
||||||
request: GiteaWriteValidationRequestDto,
|
|
||||||
dependencies: CredentialValidationDependencies,
|
|
||||||
): Promise<CredentialValidationResultDto> {
|
|
||||||
if (!dependencies.estateRegistry.matches(request.estate, request.host)) {
|
|
||||||
return refused(request, {
|
|
||||||
code: 'estate-host-mismatch',
|
|
||||||
message: 'The declared estate does not contain the declared host.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = await resolveCredential(request, request.identity, dependencies);
|
|
||||||
if (resolved === undefined) {
|
|
||||||
return refused(request, {
|
|
||||||
code: 'no-token-for-identity',
|
|
||||||
message: 'The explicit identity has no credential in the declared estate.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let subjectIdentity: ProviderIdentityEvidenceDto | null = null;
|
|
||||||
try {
|
|
||||||
subjectIdentity = await dependencies.provider.readIdentity(resolved);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (
|
|
||||||
!(error instanceof CredentialProviderEvidenceError) ||
|
|
||||||
error.code !== 'identity-read-forbidden'
|
|
||||||
) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const subjectPermission = await dependencies.provider.readRepositoryPermission(
|
|
||||||
resolved,
|
|
||||||
request.repo,
|
|
||||||
);
|
|
||||||
const subjectReceivePack = await dependencies.provider.probeReceivePack(resolved, request.repo);
|
|
||||||
const baseEvidence: CredentialValidationEvidenceDto = {
|
|
||||||
providerIdentity: subjectIdentity,
|
|
||||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
|
||||||
repositoryPermission: subjectPermission,
|
|
||||||
writeDifferential: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (subjectIdentity !== null && !identityContentTypeValid(subjectIdentity)) {
|
|
||||||
return indeterminate(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'unexpected-content-type',
|
|
||||||
message: 'The provider identity response was not JSON.',
|
|
||||||
},
|
|
||||||
baseEvidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (subjectIdentity !== null && subjectIdentity.login !== request.identity) {
|
|
||||||
return refused(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'provider-identity-mismatch',
|
|
||||||
message: 'The provider credential identity does not equal the declared subject.',
|
|
||||||
},
|
|
||||||
baseEvidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!permissionContentTypeValid(subjectPermission)) {
|
|
||||||
return indeterminate(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'unexpected-content-type',
|
|
||||||
message: 'The provider repository response was not JSON.',
|
|
||||||
},
|
|
||||||
baseEvidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (request.requiredPermission === 'admin' && subjectPermission.effective !== 'admin') {
|
|
||||||
return refused(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'permission-denied',
|
|
||||||
message: 'The provider repository object denies required admin permission.',
|
|
||||||
},
|
|
||||||
baseEvidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (subjectPermission.effective === 'read' || subjectPermission.effective === 'none') {
|
|
||||||
return refused(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'permission-denied',
|
|
||||||
message: 'The provider repository object denies write permission.',
|
|
||||||
},
|
|
||||||
baseEvidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
subjectReceivePack.principal !== request.identity ||
|
|
||||||
subjectReceivePack.resolutionId !== resolved.resolutionId
|
|
||||||
) {
|
|
||||||
return indeterminate(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'transport-principal-mismatch',
|
|
||||||
message: 'The write transport evidence is not bound to the declared subject credential.',
|
|
||||||
},
|
|
||||||
baseEvidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!advertised(subjectReceivePack)) {
|
|
||||||
return indeterminate(
|
|
||||||
request,
|
|
||||||
{
|
|
||||||
code: 'permission-evidence-disagrees',
|
|
||||||
message: 'Repository permission and write transport evidence disagree.',
|
|
||||||
},
|
|
||||||
baseEvidence,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const control = await resolveCredential(request, request.readOnlyControlIdentity, dependencies);
|
|
||||||
if (control === undefined) {
|
|
||||||
return indeterminate(request, {
|
|
||||||
code: 'read-only-control-invalid',
|
|
||||||
message: 'The configured read-only control credential could not be resolved.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const controlIdentity = await dependencies.provider.readIdentity(control);
|
|
||||||
const controlPermission = await dependencies.provider.readRepositoryPermission(
|
|
||||||
control,
|
|
||||||
request.repo,
|
|
||||||
);
|
|
||||||
const controlReceivePack = await dependencies.provider.probeReceivePack(control, request.repo);
|
|
||||||
|
|
||||||
const controlIsDistinct =
|
|
||||||
request.readOnlyControlIdentity !== request.identity &&
|
|
||||||
control.resolutionId !== resolved.resolutionId;
|
|
||||||
const controlIdentityMatches =
|
|
||||||
identityContentTypeValid(controlIdentity) &&
|
|
||||||
controlIdentity.login === request.readOnlyControlIdentity;
|
|
||||||
const controlPermissionIsReadOnly =
|
|
||||||
permissionContentTypeValid(controlPermission) && controlPermission.effective === 'read';
|
|
||||||
const controlTransportIsBoundAndRefused =
|
|
||||||
controlReceivePack.state === 'refused' &&
|
|
||||||
controlReceivePack.principal === request.readOnlyControlIdentity &&
|
|
||||||
controlReceivePack.resolutionId === control.resolutionId &&
|
|
||||||
!controlReceivePack.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE);
|
|
||||||
if (
|
|
||||||
!controlIsDistinct ||
|
|
||||||
!controlIdentityMatches ||
|
|
||||||
!controlPermissionIsReadOnly ||
|
|
||||||
!controlTransportIsBoundAndRefused
|
|
||||||
) {
|
|
||||||
return indeterminate(request, {
|
|
||||||
code: 'read-only-control-invalid',
|
|
||||||
message:
|
|
||||||
'The read-only control was absent, identity-mismatched, write-capable, unbound, or admitted to write transport.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const unauthenticated = await dependencies.provider.probeReceivePack(undefined, request.repo);
|
|
||||||
if (
|
|
||||||
unauthenticated.state !== 'refused' ||
|
|
||||||
unauthenticated.contentType.toLowerCase().startsWith(RECEIVE_PACK_CONTENT_TYPE)
|
|
||||||
) {
|
|
||||||
return indeterminate(request, {
|
|
||||||
code: 'permission-evidence-disagrees',
|
|
||||||
message: 'The unauthenticated write-transport control was not refused.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const evidence = successfulEvidence(
|
|
||||||
request.identity,
|
|
||||||
subjectIdentity,
|
|
||||||
subjectPermission,
|
|
||||||
subjectReceivePack,
|
|
||||||
controlIdentity,
|
|
||||||
controlPermission,
|
|
||||||
controlReceivePack,
|
|
||||||
);
|
|
||||||
return result(request, {
|
|
||||||
outcome: subjectIdentity === null ? 'indeterminate' : 'ok',
|
|
||||||
code: subjectIdentity === null ? 'identity-not-measured' : 'validation-verified',
|
|
||||||
message:
|
|
||||||
subjectIdentity === null
|
|
||||||
? 'Write capability and both controls were confirmed, but identity was not measured because this least-privilege token cannot read /user.'
|
|
||||||
: 'Every required provider evidence layer agreed.',
|
|
||||||
evidence,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function evaluateGiteaWriteValidation(
|
|
||||||
request: GiteaWriteValidationRequestDto,
|
|
||||||
dependencies: CredentialValidationDependencies,
|
|
||||||
): Promise<CredentialValidationResultDto> {
|
|
||||||
try {
|
|
||||||
return await evaluateGiteaWriteValidationUnsafe(request, dependencies);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof CredentialProviderEvidenceError) {
|
|
||||||
return providerEvidenceFailure(request, error);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,8 +22,6 @@ export interface SecureFileSnapshot {
|
|||||||
mode: number;
|
mode: number;
|
||||||
dev: number | bigint;
|
dev: number | bigint;
|
||||||
ino: number | bigint;
|
ino: number | bigint;
|
||||||
uid: number;
|
|
||||||
gid: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameIdentity(
|
function sameIdentity(
|
||||||
@@ -237,8 +235,6 @@ export function readRegularFileSecure(
|
|||||||
mode: Number(opened.mode),
|
mode: Number(opened.mode),
|
||||||
dev: opened.dev,
|
dev: opened.dev,
|
||||||
ino: opened.ino,
|
ino: opened.ino,
|
||||||
uid: opened.uid,
|
|
||||||
gid: opened.gid,
|
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
closeDescriptors(openedFile.descriptors);
|
closeDescriptors(openedFile.descriptors);
|
||||||
|
|||||||
+434
-163
@@ -1,184 +1,455 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ─── Mosaic Stack — End-to-End Install Test ────────────────────────────────────
|
# Greenfield installer acceptance fixture.
|
||||||
#
|
#
|
||||||
# Runs a clean-container install test to verify the full first-run flow:
|
# The fixture itself is intentionally RED until the C2-C5 phase owners repair
|
||||||
# tools/install.sh -> mosaic wizard (non-interactive)
|
# their postconditions. C1's CI gate executes it and validates that the RED is
|
||||||
# -> mosaic gateway install
|
# attributable (including the discriminating P3 PASS); it does not turn the
|
||||||
# -> mosaic gateway verify
|
# failed install into a false green.
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# bash tools/e2e-install-test.sh
|
|
||||||
#
|
|
||||||
# Requirements:
|
|
||||||
# - Docker (skips gracefully if not available)
|
|
||||||
# - Run from the repository root
|
|
||||||
#
|
|
||||||
# How it works:
|
|
||||||
# 1. Mounts the repository into a node:22-alpine container.
|
|
||||||
# 2. Installs prerequisites (bash, curl, jq, git) inside the container.
|
|
||||||
# 3. Runs `bash tools/install.sh --yes --no-auto-launch` to install the
|
|
||||||
# framework and CLI from the Gitea registry.
|
|
||||||
# 4. Runs `mosaic wizard --non-interactive` to set up SOUL/USER.
|
|
||||||
# 5. Runs `mosaic gateway install` with piped defaults (non-interactive).
|
|
||||||
# 6. Runs `mosaic gateway verify` and checks its exit code.
|
|
||||||
# NOTE: `mosaic gateway verify` is a new command added in the
|
|
||||||
# feat/mosaic-first-run-ux branch. If the installed CLI version
|
|
||||||
# pre-dates this branch (does not have `gateway verify`), the test
|
|
||||||
# marks this step as EXPECTED-SKIP and reports the installed version.
|
|
||||||
# 7. Reports PASS or FAIL with a summary.
|
|
||||||
#
|
|
||||||
# To run manually:
|
|
||||||
# cd /path/to/mosaic-stack
|
|
||||||
# bash tools/e2e-install-test.sh
|
|
||||||
#
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
IMAGE="node:22-alpine"
|
LANE="${MOSAIC_INSTALL_LANE:-next}"
|
||||||
CONTAINER_NAME="mosaic-e2e-install-$$"
|
SOURCE="${MOSAIC_INSTALL_SOURCE:-checkout}"
|
||||||
|
IMAGE="${MOSAIC_INSTALL_IMAGE:-node:22-bookworm-slim}"
|
||||||
|
GIT_MODE="${MOSAIC_INSTALL_GIT_MODE:-present}"
|
||||||
|
INSTALLER_FILE="${MOSAIC_FIXTURE_INSTALLER_FILE:-$ROOT/tools/install.sh}"
|
||||||
|
INSTALLER_URL="${MOSAIC_FIXTURE_INSTALLER_URL:-}"
|
||||||
|
INSTALLER_SHA256="${MOSAIC_FIXTURE_INSTALLER_SHA256:-}"
|
||||||
|
SOURCE_COMMIT="${MOSAIC_FIXTURE_SOURCE_COMMIT:-}"
|
||||||
|
SOURCE_SHA256="${MOSAIC_FIXTURE_SOURCE_SHA256:-}"
|
||||||
|
IN_CLEAN_CONTAINER="${MOSAIC_GREENFIELD_CONTAINER:-0}"
|
||||||
|
|
||||||
# ─── Colour helpers ───────────────────────────────────────────────────────────
|
usage() {
|
||||||
if [[ -t 1 ]]; then
|
cat <<'EOF'
|
||||||
R=$'\033[0;31m' G=$'\033[0;32m' Y=$'\033[0;33m' BOLD=$'\033[1m' RESET=$'\033[0m'
|
Usage: tools/e2e-install-test.sh [--lane next|main] [--source checkout|remote] [--git present|absent]
|
||||||
else
|
|
||||||
R="" G="" Y="" BOLD="" RESET=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
info() { echo "${BOLD}[e2e]${RESET} $*"; }
|
Runs the documented installer command from zero in Debian/glibc as a non-root
|
||||||
ok() { echo "${G}[PASS]${RESET} $*"; }
|
uid with an isolated HOME. The fixture exits non-zero when any P0-P8
|
||||||
fail() { echo "${R}[FAIL]${RESET} $*" >&2; }
|
postcondition fails. `next` is always selected with the --next installer flag.
|
||||||
warn() { echo "${Y}[WARN]${RESET} $*"; }
|
EOF
|
||||||
|
|
||||||
# ─── Docker availability check ────────────────────────────────────────────────
|
|
||||||
if ! command -v docker &>/dev/null; then
|
|
||||||
warn "Docker not found — skipping e2e install test."
|
|
||||||
warn "Install Docker and re-run this script to exercise the full install flow."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! docker info &>/dev/null 2>&1; then
|
|
||||||
warn "Docker daemon is not running or not accessible — skipping e2e install test."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Docker available — proceeding with e2e install test."
|
|
||||||
info "Repo root: ${REPO_ROOT}"
|
|
||||||
info "Container image: ${IMAGE}"
|
|
||||||
|
|
||||||
# ─── Inline script that runs INSIDE the container ────────────────────────────
|
|
||||||
INNER_SCRIPT="$(mktemp /tmp/mosaic-e2e-inner-XXXXXX.sh)"
|
|
||||||
trap 'rm -f "$INNER_SCRIPT"' EXIT
|
|
||||||
|
|
||||||
cat > "$INNER_SCRIPT" <<'INNER_SCRIPT_EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
# Bootstrap: /bin/sh until bash is installed, then re-exec.
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== [inner] Installing system prerequisites ==="
|
|
||||||
apk add --no-cache bash curl jq git 2>/dev/null || \
|
|
||||||
apt-get install -y -q bash curl jq git 2>/dev/null || true
|
|
||||||
|
|
||||||
# Re-exec under bash.
|
|
||||||
if [ -z "${BASH_VERSION:-}" ] && command -v bash >/dev/null 2>&1; then
|
|
||||||
exec bash "$0" "$@"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── bash from here ────────────────────────────────────────────────────────────
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "=== [inner] Node.js / npm versions ==="
|
|
||||||
node --version
|
|
||||||
npm --version
|
|
||||||
|
|
||||||
echo "=== [inner] Setting up npm global prefix ==="
|
|
||||||
export NPM_PREFIX="/root/.npm-global"
|
|
||||||
mkdir -p "$NPM_PREFIX/bin"
|
|
||||||
npm config set prefix "$NPM_PREFIX" 2>/dev/null || true
|
|
||||||
export PATH="$NPM_PREFIX/bin:$PATH"
|
|
||||||
|
|
||||||
echo "=== [inner] Running install.sh --yes --no-auto-launch ==="
|
|
||||||
# Install both framework and CLI from the Gitea registry.
|
|
||||||
MOSAIC_SKIP_SKILLS_SYNC=1 \
|
|
||||||
MOSAIC_ASSUME_YES=1 \
|
|
||||||
bash /repo/tools/install.sh --yes --no-auto-launch
|
|
||||||
|
|
||||||
INSTALLED_VERSION="$(mosaic --version 2>/dev/null || echo 'unknown')"
|
|
||||||
echo "[inner] mosaic CLI installed: ${INSTALLED_VERSION}"
|
|
||||||
|
|
||||||
echo "=== [inner] Running mosaic wizard (non-interactive) ==="
|
|
||||||
mosaic wizard \
|
|
||||||
--non-interactive \
|
|
||||||
--name "test-agent" \
|
|
||||||
--user-name "tester" \
|
|
||||||
--pronouns "they/them" \
|
|
||||||
--timezone "UTC" || {
|
|
||||||
echo "[WARN] mosaic wizard exited non-zero — continuing"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "=== [inner] Running mosaic gateway install ==="
|
while [[ $# -gt 0 ]]; do
|
||||||
# Feed non-interactive answers:
|
case "$1" in
|
||||||
# "1" → storage tier: local
|
--lane) LANE="${2:-}"; shift 2 ;;
|
||||||
# "" → port: accept default (14242)
|
--source) SOURCE="${2:-}"; shift 2 ;;
|
||||||
# "" → ANTHROPIC_API_KEY: skip
|
--git) GIT_MODE="${2:-}"; shift 2 ;;
|
||||||
# "" → CORS origin: accept default
|
-h|--help) usage; exit 0 ;;
|
||||||
# Then admin bootstrap: name, email, password
|
*) echo "[fixture] unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||||
printf '1\n\n\n\nTest Admin\[email protected]\ntestpassword123\n' \
|
esac
|
||||||
| mosaic gateway install
|
done
|
||||||
INSTALL_EXIT="$?"
|
|
||||||
if [ "${INSTALL_EXIT}" -ne 0 ]; then
|
case "$LANE" in next|main) ;; *) echo "[fixture] unsupported lane '$LANE' (expected next|main)" >&2; exit 2 ;; esac
|
||||||
echo "[ERR] mosaic gateway install exited ${INSTALL_EXIT}"
|
case "$SOURCE" in checkout|remote) ;; *) echo "[fixture] unsupported source '$SOURCE' (expected checkout|remote)" >&2; exit 2 ;; esac
|
||||||
mosaic gateway status 2>/dev/null || true
|
case "$GIT_MODE" in present|absent) ;; *) echo "[fixture] unsupported git mode '$GIT_MODE' (expected present|absent)" >&2; exit 2 ;; esac
|
||||||
exit "${INSTALL_EXIT}"
|
if [[ "$SOURCE" == remote ]]; then
|
||||||
|
[[ -n "$INSTALLER_URL" ]] || INSTALLER_URL="https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/${LANE}/tools/install.sh"
|
||||||
|
[[ "$INSTALLER_SHA256" =~ ^[0-9a-f]{64}$ ]] \
|
||||||
|
|| { echo '[fixture] remote source requires MOSAIC_FIXTURE_INSTALLER_SHA256=64hex' >&2; exit 2; }
|
||||||
|
[[ "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] \
|
||||||
|
|| { echo '[fixture] remote source requires MOSAIC_FIXTURE_SOURCE_COMMIT=40hex' >&2; exit 2; }
|
||||||
|
[[ -z "$SOURCE_SHA256" || "$SOURCE_SHA256" =~ ^[0-9a-f]{64}$ ]] \
|
||||||
|
|| { echo '[fixture] MOSAIC_FIXTURE_SOURCE_SHA256 must be empty or 64hex' >&2; exit 2; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "=== [inner] Running mosaic gateway verify ==="
|
if [[ "$IN_CLEAN_CONTAINER" != "1" ]]; then
|
||||||
# `gateway verify` was added in feat/mosaic-first-run-ux.
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
# If the installed version pre-dates this, skip gracefully.
|
echo "[fixture] FAIL: Docker is required; greenfield validation was NOT RUN." >&2
|
||||||
if ! mosaic gateway --help 2>&1 | grep -q 'verify'; then
|
exit 2
|
||||||
echo "[SKIP] 'mosaic gateway verify' not available in installed version ${INSTALLED_VERSION}."
|
fi
|
||||||
echo "[SKIP] This command was added in the feat/mosaic-first-run-ux release."
|
if ! docker info >/dev/null 2>&1; then
|
||||||
echo "[SKIP] Re-run after the new version is published to validate this step."
|
echo "[fixture] FAIL: Docker daemon is unavailable; greenfield validation was NOT RUN." >&2
|
||||||
# Treat as pass — the install flow itself worked.
|
exit 2
|
||||||
exit 0
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mosaic gateway verify
|
installer_b64=""
|
||||||
VERIFY_EXIT="$?"
|
framework_payload_count="NOT-MEASURED"
|
||||||
echo "=== [inner] verify exit code: ${VERIFY_EXIT} ==="
|
repo_root_count="NOT-MEASURED"
|
||||||
exit "${VERIFY_EXIT}"
|
checkout_archive=""
|
||||||
INNER_SCRIPT_EOF
|
checkout_digest=""
|
||||||
|
checkout_content_id=""
|
||||||
|
if [[ "$SOURCE" == "checkout" ]]; then
|
||||||
|
installer_b64="$(base64 -w0 "$INSTALLER_FILE")"
|
||||||
|
[[ -d "$ROOT/packages/mosaic/framework/skills" ]] \
|
||||||
|
&& framework_payload_count="$(find "$ROOT/packages/mosaic/framework/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||||
|
[[ -d "$ROOT/skills" ]] \
|
||||||
|
&& repo_root_count="$(find "$ROOT/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||||
|
checkout_archive="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-checkout.XXXXXX")"
|
||||||
|
repo_parent="$(dirname "$ROOT")"
|
||||||
|
repo_name="$(basename "$ROOT")"
|
||||||
|
tar -C "$repo_parent" \
|
||||||
|
--exclude='*/.git' --exclude='*/node_modules' --exclude='*/dist' \
|
||||||
|
--exclude='*/coverage' --exclude='*/.turbo' --exclude='*/.mosaic-test-work' \
|
||||||
|
--exclude='*/.env' --exclude='*/.env.*' \
|
||||||
|
-czf "$checkout_archive" "$repo_name"
|
||||||
|
checkout_digest="$(sha256sum "$checkout_archive" | awk '{print $1}')"
|
||||||
|
checkout_content_id="${checkout_digest:0:40}"
|
||||||
|
fi
|
||||||
|
|
||||||
chmod +x "$INNER_SCRIPT"
|
inner="$(mktemp "${TMPDIR:-/tmp}/mosaic-greenfield-inner.XXXXXX")"
|
||||||
|
trap 'rm -f "$inner" "$checkout_archive"' EXIT
|
||||||
|
cat > "$inner" <<'INNER'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
# ─── Pull image ───────────────────────────────────────────────────────────────
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
info "Pulling ${IMAGE}…"
|
apt-get update -qq
|
||||||
docker pull "${IMAGE}" --quiet
|
packages=(bash ca-certificates curl jq passwd python3 util-linux)
|
||||||
|
[[ "$FIXTURE_GIT_MODE" == "present" ]] && packages+=(git)
|
||||||
|
apt-get install -y -qq "${packages[@]}" >/dev/null
|
||||||
|
|
||||||
# ─── Run container ────────────────────────────────────────────────────────────
|
source_archive=/tmp/source-checkout.tar.gz
|
||||||
info "Starting container ${CONTAINER_NAME}…"
|
source_commit=""
|
||||||
|
source_sha256=""
|
||||||
|
expected_source_commit=""
|
||||||
|
expected_source_sha256=""
|
||||||
|
|
||||||
EXIT_CODE=0
|
if [[ "$FIXTURE_SOURCE" == "checkout" ]]; then
|
||||||
docker run --rm \
|
awk 'found { print } /^__MOSAIC_CHECKOUT_ARCHIVE__$/ { found=1; next }' "$0" | base64 -d > "$source_archive"
|
||||||
--name "${CONTAINER_NAME}" \
|
source_sha256="$(sha256sum "$source_archive" | awk '{print $1}')"
|
||||||
--volume "${REPO_ROOT}:/repo:ro" \
|
if [[ "$source_sha256" != "$FIXTURE_CHECKOUT_SHA256" ]]; then
|
||||||
--volume "${INNER_SCRIPT}:/e2e-inner.sh:ro" \
|
echo "[fixture] checkout archive transport digest mismatch" >&2
|
||||||
--network host \
|
exit 1
|
||||||
"${IMAGE}" \
|
fi
|
||||||
/bin/sh /e2e-inner.sh \
|
source_commit="$FIXTURE_CHECKOUT_CONTENT_ID"
|
||||||
|| EXIT_CODE=$?
|
fi
|
||||||
|
|
||||||
# ─── Report ───────────────────────────────────────────────────────────────────
|
useradd --create-home --uid 1001 --shell /bin/bash mosaic
|
||||||
echo ""
|
install -d -o mosaic -g mosaic /home/mosaic/work
|
||||||
if [[ "$EXIT_CODE" -eq 0 ]]; then
|
|
||||||
ok "End-to-end install test PASSED (exit ${EXIT_CODE})"
|
case "$FIXTURE_SOURCE" in
|
||||||
|
checkout)
|
||||||
|
printf '%s' "$FIXTURE_INSTALLER_B64" | base64 -d > /tmp/install.sh
|
||||||
|
;;
|
||||||
|
remote)
|
||||||
|
curl -fsSL "$FIXTURE_INSTALLER_URL" -o /tmp/install.sh
|
||||||
|
[[ -s /tmp/install.sh ]] || { echo '[fixture] remote installer returned an empty HTTP-success body' >&2; exit 1; }
|
||||||
|
actual_installer_sha256="$(sha256sum /tmp/install.sh | awk '{print $1}')"
|
||||||
|
[[ "$actual_installer_sha256" == "$FIXTURE_INSTALLER_SHA256" ]] || {
|
||||||
|
echo "[fixture] remote installer digest mismatch got=$actual_installer_sha256 expected=$FIXTURE_INSTALLER_SHA256" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
curl -fsSL --max-filesize 268435456 \
|
||||||
|
"https://git.mosaicstack.dev/mosaicstack/stack/archive/${FIXTURE_SOURCE_COMMIT}.tar.gz" \
|
||||||
|
-o "$source_archive"
|
||||||
|
[[ -s "$source_archive" ]] || { echo '[fixture] remote source archive returned an empty HTTP-success body' >&2; exit 1; }
|
||||||
|
source_sha256="$(sha256sum "$source_archive" | awk '{print $1}')"
|
||||||
|
if [[ -n "$FIXTURE_SOURCE_SHA256" && "$source_sha256" != "$FIXTURE_SOURCE_SHA256" ]]; then
|
||||||
|
echo "[fixture] remote source archive digest mismatch got=$source_sha256 expected=$FIXTURE_SOURCE_SHA256" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
source_commit="$FIXTURE_SOURCE_COMMIT"
|
||||||
|
expected_source_commit="$source_commit"
|
||||||
|
expected_source_sha256="$source_sha256"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
chmod 0755 /tmp/install.sh
|
||||||
|
sha256sum /tmp/install.sh | sed 's/^/[fixture] installer sha256: /'
|
||||||
|
|
||||||
|
cat > /tmp/run-as-target.sh <<'TARGET'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
lane="$FIXTURE_LANE"
|
||||||
|
home="$HOME"
|
||||||
|
prefix="$home/.npm-global"
|
||||||
|
mosaic_home="$home/.config/mosaic"
|
||||||
|
install_log="$home/install.log"
|
||||||
|
failures=0
|
||||||
|
|
||||||
|
phase_pass() { printf '[%s] PASS: %s\n' "$1" "$2"; }
|
||||||
|
phase_fail() { printf '[%s] FAIL: %s\n' "$1" "$2"; failures=$((failures + 1)); }
|
||||||
|
|
||||||
|
lane_args=()
|
||||||
|
resolved_spec='@mosaicstack/mosaic'
|
||||||
|
if [[ "$lane" == "next" ]]; then
|
||||||
|
lane_args+=(--next)
|
||||||
|
resolved_spec='@mosaicstack/mosaic@next'
|
||||||
|
fi
|
||||||
|
|
||||||
|
resolved_version="$(npm view "$resolved_spec" version --registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ 2>/dev/null || true)"
|
||||||
|
printf '[fixture] resolved lane=%s package=%s version=%s\n' "$lane" "$resolved_spec" "${resolved_version:-UNRESOLVED}"
|
||||||
|
|
||||||
|
secret_canary='MOSAIC_C1_CANARY_6f3c91e2'
|
||||||
|
argv_capture=/tmp/mosaic-installer-argv.log
|
||||||
|
: > "$argv_capture"
|
||||||
|
set +e
|
||||||
|
MOSAIC_NO_COLOR=1 MOSAIC_ASSUME_YES=1 MOSAIC_INSTALL_SECRET_CANARY="$secret_canary" \
|
||||||
|
MOSAIC_INSTALL_REDACTION_PROBE=1 \
|
||||||
|
bash /tmp/install.sh "${lane_args[@]}" --yes --no-auto-launch >"$install_log" 2>&1 &
|
||||||
|
installer_pid=$!
|
||||||
|
while kill -0 "$installer_pid" 2>/dev/null; do
|
||||||
|
for cmdline in /proc/[0-9]*/cmdline; do
|
||||||
|
[[ -r "$cmdline" ]] || continue
|
||||||
|
tr '\0' ' ' < "$cmdline" >> "$argv_capture" 2>/dev/null || true
|
||||||
|
printf '\n' >> "$argv_capture"
|
||||||
|
done
|
||||||
|
sleep 0.02
|
||||||
|
done
|
||||||
|
wait "$installer_pid"
|
||||||
|
install_status=$?
|
||||||
|
set -e
|
||||||
|
cat "$install_log"
|
||||||
|
|
||||||
|
if [[ -n "$FIXTURE_EXPECTED_SOURCE_COMMIT" ]]; then
|
||||||
|
manifest="$mosaic_home/.install-manifest.json"
|
||||||
|
realised_source_commit="$(node -p "require('$manifest').sourceCommit || ''" 2>/dev/null || true)"
|
||||||
|
realised_source_sha256="$(node -p "require('$manifest').sourceSha256 || ''" 2>/dev/null || true)"
|
||||||
|
if [[ "$realised_source_commit" != "$FIXTURE_EXPECTED_SOURCE_COMMIT" \
|
||||||
|
|| "$realised_source_sha256" != "$FIXTURE_EXPECTED_SOURCE_SHA256" ]]; then
|
||||||
|
printf '[SOURCE-CONTROL] FAIL: realised_commit=%s expected_commit=%s realised_sha256=%s expected_sha256=%s\n' \
|
||||||
|
"${realised_source_commit:-missing}" "$FIXTURE_EXPECTED_SOURCE_COMMIT" \
|
||||||
|
"${realised_source_sha256:-missing}" "$FIXTURE_EXPECTED_SOURCE_SHA256" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '[SOURCE-CONTROL] PASS: realised_commit=%s realised_sha256=%s\n' \
|
||||||
|
"$realised_source_commit" "$realised_source_sha256"
|
||||||
|
fi
|
||||||
|
|
||||||
|
probe_ok=true
|
||||||
|
if [[ "$FIXTURE_GIT_MODE" == present ]] \
|
||||||
|
&& ! grep -q '^\[REDACTION-PROBE\] emitted=\[REDACTED\]$' "$install_log"; then
|
||||||
|
probe_ok=false
|
||||||
|
fi
|
||||||
|
if [[ "$probe_ok" != true ]] \
|
||||||
|
|| grep -F "$secret_canary" "$argv_capture" >/dev/null \
|
||||||
|
|| grep -R -F "$secret_canary" "$home" >/dev/null 2>&1; then
|
||||||
|
phase_fail P0 'seeded credential probe missing or canary leaked to argv, output, command log, npmrc, generated files, or shell history'
|
||||||
else
|
else
|
||||||
fail "End-to-end install test FAILED (exit ${EXIT_CODE})"
|
printf '[SECRET-CONTROL] PASS: seeded captured-command canary was redacted and absent from argv/output/commands.log/npmrc/generated/history populations\n'
|
||||||
echo ""
|
fi
|
||||||
echo " Troubleshooting:"
|
printf '[fixture] installer_exit=%d done_claims=%s\n' \
|
||||||
echo " - Review the output above for the failing step."
|
"$install_status" "$(grep -cF 'Done.' "$install_log" || true)"
|
||||||
echo " - Re-run with bash -x tools/e2e-install-test.sh for verbose trace."
|
|
||||||
echo " - Run mosaic gateway logs inside a manual container for daemon output."
|
# P0 Resolve context. Keep this final fixture row as discriminating as the
|
||||||
|
# installer's own P0 row: the expected-RED comparator binds this exact reason.
|
||||||
|
passwd_row="$(getent passwd "$(id -u)")"
|
||||||
|
target_user="$(printf '%s' "$passwd_row" | cut -d: -f1)"
|
||||||
|
passwd_home="$(printf '%s' "$passwd_row" | cut -d: -f6)"
|
||||||
|
shell="$(printf '%s' "$passwd_row" | cut -d: -f7)"
|
||||||
|
if [[ "$(id -u)" -eq 1001 && "$target_user" == "mosaic" \
|
||||||
|
&& "$home" == "/home/mosaic" && "$home" == "$passwd_home" && "$shell" == "/bin/bash" ]] \
|
||||||
|
&& ldd --version 2>&1 | grep -i 'glibc\|gnu libc' >/dev/null \
|
||||||
|
&& [[ "$(node -p 'Number(process.versions.node.split(".")[0])')" -ge 20 ]]; then
|
||||||
|
phase_pass P0 "target=$target_user uid=$(id -u) HOME=$home passwd_HOME=$passwd_home shell=$shell privilege=user arch=$(uname -m) libc=glibc node=$(node --version) npm=$(npm --version)"
|
||||||
|
else
|
||||||
|
phase_fail P0 "context unresolved or unsupported (target=${target_user:-unknown} uid=$(id -u) HOME=$home passwd_HOME=${passwd_home:-unknown} shell=${shell:-unknown} privilege=user)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# P1 Preflight
|
||||||
|
missing_tools=()
|
||||||
|
for tool in bash curl git node npm python3 tar; do
|
||||||
|
command -v "$tool" >/dev/null 2>&1 || missing_tools+=("$tool")
|
||||||
|
done
|
||||||
|
if [[ "${#missing_tools[@]}" -eq 0 && -n "$resolved_version" && -w "$home" ]]; then
|
||||||
|
phase_pass P1 "required tools present (including downstream git); target HOME writable; registry lane resolved"
|
||||||
|
else
|
||||||
|
phase_fail P1 "undeclared/missing prerequisite(s)=${missing_tools[*]:-none}; target_writable=$([[ -w "$home" ]] && echo yes || echo no) registry_resolved=$([[ -n "$resolved_version" ]] && echo yes || echo no)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# P2 Acquire artifacts
|
||||||
|
if [[ -n "$resolved_version" ]] && grep -qF "$resolved_version" "$install_log"; then
|
||||||
|
phase_pass P2 "lane=$lane pinned_version=$resolved_version recorded in installer transcript"
|
||||||
|
else
|
||||||
|
phase_fail P2 "lane=$lane did not resolve and record a pinned artifact version"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# P3 Install CLI — the discriminating row. Use the known absolute path only.
|
||||||
|
cli="$prefix/bin/mosaic"
|
||||||
|
cli_version=""
|
||||||
|
if [[ -x "$cli" ]]; then
|
||||||
|
cli_version="$($cli --version 2>/dev/null | tail -n 1 | tr -d '\r' || true)"
|
||||||
|
fi
|
||||||
|
if [[ -x "$cli" && "$cli_version" == "$resolved_version" ]]; then
|
||||||
|
phase_pass P3 "absolute_path=$cli version=$cli_version equals resolved lane version"
|
||||||
|
else
|
||||||
|
phase_fail P3 "absolute_path=$cli executable=$([[ -x "$cli" ]] && echo yes || echo no) got=${cli_version:-missing} expected=${resolved_version:-unresolved}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# P4 Framework + skills. C1 does not choose among the four disagreeing
|
||||||
|
# candidate populations. It requires the installer to publish a lane/versioned
|
||||||
|
# shipped-set declaration that a checkout-free install can resolve; C5 owns its
|
||||||
|
# contents. Without that artifact P4 is NOT-MEASURED, never a fabricated count.
|
||||||
|
declared_set="$mosaic_home/.install-shipped-skills.json"
|
||||||
|
sync_store_count=0
|
||||||
|
runtime_link_count=0
|
||||||
|
[[ -d "$mosaic_home/skills" ]] \
|
||||||
|
&& sync_store_count="$(find "$mosaic_home/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
|
||||||
|
[[ -d "$home/.pi/agent/skills" ]] \
|
||||||
|
&& runtime_link_count="$(find "$home/.pi/agent/skills" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) | wc -l | tr -d ' ')"
|
||||||
|
printf '[P4-EVIDENCE] candidate_populations framework_payload=%s repo_root=%s sync_store=%s jarvis_W-jarvis_observation=7 runtime_links=%s\n' \
|
||||||
|
"$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" "$FIXTURE_REPO_ROOT_COUNT" "$sync_store_count" "$runtime_link_count"
|
||||||
|
if [[ ! -s "$declared_set" ]]; then
|
||||||
|
phase_fail P4 "NOT-MEASURED / UNDECLARED: installer published no checkout-free, lane/versioned shipped-set artifact at $declared_set"
|
||||||
|
elif EXPECTED_LANE="$([[ "$lane" == next ]] && echo next || echo latest)" EXPECTED_VERSION="$resolved_version" \
|
||||||
|
MOSAIC_SKILLS_ROOT="$mosaic_home/skills" node - "$declared_set" <<'NODE'
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||||
|
const root = path.resolve(process.env.MOSAIC_SKILLS_ROOT);
|
||||||
|
if (!data || data.lane !== process.env.EXPECTED_LANE || data.version !== process.env.EXPECTED_VERSION ||
|
||||||
|
!Array.isArray(data.skills) || data.skills.length === 0) process.exit(1);
|
||||||
|
for (const name of data.skills) {
|
||||||
|
if (typeof name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) process.exit(1);
|
||||||
|
const skill = path.join(root, name, 'SKILL.md');
|
||||||
|
let real;
|
||||||
|
try { real = fs.realpathSync(skill); } catch { process.exit(1); }
|
||||||
|
const text = fs.readFileSync(real, 'utf8');
|
||||||
|
const declaredName = text.match(/^---\s*$[\s\S]*?^name:\s*([^\s]+)\s*$/m)?.[1];
|
||||||
|
if (!real.startsWith(root + path.sep) || !fs.statSync(real).isFile() || !text || declaredName !== name) process.exit(1);
|
||||||
|
}
|
||||||
|
NODE
|
||||||
|
then
|
||||||
|
declared_count="$(node -p "require('$declared_set').skills.length")"
|
||||||
|
if [[ -s "$mosaic_home/.install-manifest.json" ]] \
|
||||||
|
&& [[ "$(node -p "require('$mosaic_home/.install-manifest.json').phaseOutcomes?.P4 || 'committed'")" == failed ]]; then
|
||||||
|
phase_fail P4 "declared skills are present but the required framework/skills action reported failure"
|
||||||
|
else
|
||||||
|
phase_pass P4 "declared shipped-set matches lane/version and all $declared_count skill(s) are contained and loadable"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
phase_fail P4 "shipped-set artifact is malformed, wrong-lane/version, or its declared skills are not contained and loadable"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# P5 Identity
|
||||||
|
identity_ok=true
|
||||||
|
identity_reason=()
|
||||||
|
for f in SOUL.md USER.md; do
|
||||||
|
path="$mosaic_home/$f"
|
||||||
|
if [[ ! -s "$path" ]]; then
|
||||||
|
identity_ok=false; identity_reason+=("$f missing-or-empty"); continue
|
||||||
|
fi
|
||||||
|
owner="$(stat -c '%u' "$path")"; mode="$(stat -c '%a' "$path")"
|
||||||
|
if [[ "$owner" != "$(id -u)" || "$mode" =~ [2367]$ ]]; then
|
||||||
|
identity_ok=false; identity_reason+=("$f owner=$owner mode=$mode")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ "$identity_ok" == true ]]; then
|
||||||
|
phase_pass P5 "SOUL.md and USER.md are non-empty and target-user owned with non-world-writable modes"
|
||||||
|
else
|
||||||
|
phase_fail P5 "${identity_reason[*]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# P6 Runtime linking / activation. #869 must remain unwired without its broker.
|
||||||
|
manifest="$mosaic_home/.install-manifest.json"
|
||||||
|
broker_present=false
|
||||||
|
[[ -S "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/mosaic-lease/broker.sock" ]] && broker_present=true
|
||||||
|
dead_hooks=0
|
||||||
|
if [[ -f "$home/.claude/settings.json" ]]; then
|
||||||
|
dead_hooks="$(grep -Ec 'mutator-gate\.py|receipt-observer-client\.py' "$home/.claude/settings.json" || true)"
|
||||||
|
fi
|
||||||
|
p6_action_failed=false
|
||||||
|
if [[ -s "$manifest" ]]; then
|
||||||
|
p6_action_failed="$(node -p "require('$manifest').phaseOutcomes?.P6 === 'failed' ? 'true' : 'false'" 2>/dev/null || echo true)"
|
||||||
|
fi
|
||||||
|
if [[ "$p6_action_failed" == true ]]; then
|
||||||
|
phase_fail P6 "runtime linking/activation action reported a required failure"
|
||||||
|
elif [[ "$broker_present" == false && "$dead_hooks" -eq 0 ]]; then
|
||||||
|
phase_pass P6 "broker absent and #869 enforcement hooks remain inactive"
|
||||||
|
elif [[ "$broker_present" == true ]]; then
|
||||||
|
phase_pass P6 "activation broker present; hook state is evaluable"
|
||||||
|
else
|
||||||
|
phase_fail P6 "broker absent but dead enforcement hooks are active (count=$dead_hooks)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# P7 Services — none requested by --no-auto-launch.
|
||||||
|
phase_pass P7 "no services requested by this fixture"
|
||||||
|
|
||||||
|
# P8 Shell discoverability — actual target shell, fresh login and non-login.
|
||||||
|
base_env=(env -i HOME="$home" USER=mosaic LOGNAME=mosaic SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin)
|
||||||
|
login_path="$("${base_env[@]}" /bin/bash -lc 'command -v mosaic' 2>/dev/null || true)"
|
||||||
|
nonlogin_path="$("${base_env[@]}" /bin/bash -c 'command -v mosaic' 2>/dev/null || true)"
|
||||||
|
if [[ "$login_path" == "$cli" && "$nonlogin_path" == "$cli" ]]; then
|
||||||
|
phase_pass P8 "login=$login_path nonlogin=$nonlogin_path equals P3 path"
|
||||||
|
else
|
||||||
|
phase_fail P8 "fresh bash login=${login_path:-missing} nonlogin=${nonlogin_path:-missing} expected=$cli"
|
||||||
|
fi
|
||||||
|
|
||||||
|
manifest="$mosaic_home/.install-manifest.json"
|
||||||
|
p0_p8_failures="$failures"
|
||||||
|
if [[ "$p0_p8_failures" -eq 0 && -s "$manifest" ]]; then
|
||||||
|
phase_pass P9 "P0-P8 reasserted; manifest present"
|
||||||
|
else
|
||||||
|
phase_fail P9 "P0-P8_failed_postconditions=$p0_p8_failures manifest=$([[ -s "$manifest" ]] && echo present || echo missing); install must not certify success"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[fixture] P0-P9_failed_rows=%d (includes P9 aggregate row)\n' "$failures"
|
||||||
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
TARGET
|
||||||
|
chmod 0755 /tmp/run-as-target.sh
|
||||||
|
chown mosaic:mosaic /tmp/run-as-target.sh
|
||||||
|
|
||||||
|
exec runuser -u mosaic -- env -i \
|
||||||
|
HOME=/home/mosaic USER=mosaic LOGNAME=mosaic SHELL=/bin/bash \
|
||||||
|
PATH=/usr/local/bin:/usr/bin:/bin \
|
||||||
|
FIXTURE_LANE="$FIXTURE_LANE" \
|
||||||
|
FIXTURE_GIT_MODE="$FIXTURE_GIT_MODE" \
|
||||||
|
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$FIXTURE_FRAMEWORK_PAYLOAD_COUNT" \
|
||||||
|
FIXTURE_REPO_ROOT_COUNT="$FIXTURE_REPO_ROOT_COUNT" \
|
||||||
|
FIXTURE_EXPECTED_SOURCE_COMMIT="$expected_source_commit" \
|
||||||
|
FIXTURE_EXPECTED_SOURCE_SHA256="$expected_source_sha256" \
|
||||||
|
MOSAIC_INSTALL_LOCAL_SOURCE_ARCHIVE="$source_archive" \
|
||||||
|
MOSAIC_INSTALL_LOCAL_SOURCE_COMMIT="$source_commit" \
|
||||||
|
MOSAIC_INSTALL_LOCAL_SOURCE_SHA256="$source_sha256" \
|
||||||
|
/bin/bash /tmp/run-as-target.sh
|
||||||
|
INNER
|
||||||
|
if [[ "$SOURCE" == "checkout" ]]; then
|
||||||
|
{
|
||||||
|
printf '\n__MOSAIC_CHECKOUT_ARCHIVE__\n'
|
||||||
|
base64 "$checkout_archive"
|
||||||
|
} >> "$inner"
|
||||||
|
fi
|
||||||
|
chmod 0755 "$inner"
|
||||||
|
|
||||||
|
printf '[fixture] platform=Debian/glibc image=%s target_uid=1001 lane=%s source=%s git=%s\n' "$IMAGE" "$LANE" "$SOURCE" "$GIT_MODE"
|
||||||
|
printf '[fixture] host inheritance: no bind mounts, no host HOME, no npm cache, no credentials\n'
|
||||||
|
|
||||||
|
if [[ "$IN_CLEAN_CONTAINER" == "1" ]]; then
|
||||||
|
# Woodpecker already supplies the clean Debian container. The target install
|
||||||
|
# still runs through runuser + env -i, so CI variables/credentials do not
|
||||||
|
# enter the target user's process.
|
||||||
|
FIXTURE_LANE="$LANE" \
|
||||||
|
FIXTURE_SOURCE="$SOURCE" \
|
||||||
|
FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||||
|
FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||||
|
FIXTURE_INSTALLER_URL="$INSTALLER_URL" \
|
||||||
|
FIXTURE_INSTALLER_SHA256="$INSTALLER_SHA256" \
|
||||||
|
FIXTURE_SOURCE_COMMIT="$SOURCE_COMMIT" \
|
||||||
|
FIXTURE_SOURCE_SHA256="$SOURCE_SHA256" \
|
||||||
|
FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||||
|
FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||||
|
FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||||
|
FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||||
|
/bin/bash "$inner"
|
||||||
|
else
|
||||||
|
# Copy the self-contained script+archive into a stopped container instead of
|
||||||
|
# bind-mounting the checkout or passing host paths. The target runtime still
|
||||||
|
# inherits no host HOME/cache/credentials, and the multi-megabyte checkout
|
||||||
|
# payload avoids argv/environment size limits.
|
||||||
|
fixture_cid="$(docker create \
|
||||||
|
--network bridge \
|
||||||
|
--env FIXTURE_LANE="$LANE" \
|
||||||
|
--env FIXTURE_SOURCE="$SOURCE" \
|
||||||
|
--env FIXTURE_GIT_MODE="$GIT_MODE" \
|
||||||
|
--env FIXTURE_INSTALLER_B64="$installer_b64" \
|
||||||
|
--env FIXTURE_INSTALLER_URL="$INSTALLER_URL" \
|
||||||
|
--env FIXTURE_INSTALLER_SHA256="$INSTALLER_SHA256" \
|
||||||
|
--env FIXTURE_SOURCE_COMMIT="$SOURCE_COMMIT" \
|
||||||
|
--env FIXTURE_SOURCE_SHA256="$SOURCE_SHA256" \
|
||||||
|
--env FIXTURE_CHECKOUT_SHA256="$checkout_digest" \
|
||||||
|
--env FIXTURE_CHECKOUT_CONTENT_ID="$checkout_content_id" \
|
||||||
|
--env FIXTURE_FRAMEWORK_PAYLOAD_COUNT="$framework_payload_count" \
|
||||||
|
--env FIXTURE_REPO_ROOT_COUNT="$repo_root_count" \
|
||||||
|
"$IMAGE" /bin/bash /tmp/mosaic-greenfield-fixture.sh)"
|
||||||
|
docker cp "$inner" "$fixture_cid:/tmp/mosaic-greenfield-fixture.sh"
|
||||||
|
set +e
|
||||||
|
docker start -a "$fixture_cid"
|
||||||
|
fixture_status=$?
|
||||||
|
set -e
|
||||||
|
docker rm "$fixture_cid" >/dev/null
|
||||||
|
exit "$fixture_status"
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Required Woodpecker execution arms for C1 acceptance criterion 4.
|
||||||
|
greenfield-git-present
|
||||||
|
greenfield-main-git-present
|
||||||
|
greenfield-remote-installer-contract
|
||||||
|
greenfield-git-absent
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Pinned C1 expected-RED contract. Updating a verdict/reason requires review by the owning remediation lane.
|
||||||
|
# case kind key/value
|
||||||
|
next-git-present exit 1
|
||||||
|
next-git-present phase P0=PASS
|
||||||
|
next-git-present phase P1=PASS
|
||||||
|
next-git-present phase P2=PASS
|
||||||
|
next-git-present phase P3=PASS
|
||||||
|
next-git-present phase P4=FAIL
|
||||||
|
next-git-present phase P5=FAIL
|
||||||
|
next-git-present phase P6=FAIL
|
||||||
|
next-git-present phase P7=PASS
|
||||||
|
next-git-present phase P8=FAIL
|
||||||
|
next-git-present phase P9=FAIL
|
||||||
|
next-git-present require ^\[fixture\] resolved lane=next .*version=[0-9]+\.[0-9]+\.[0-9]+-next\.
|
||||||
|
next-git-present require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||||
|
next-git-present phase-reason P0=target=mosaic uid=1001 HOME=/home/mosaic passwd_HOME=/home/mosaic shell=/bin/bash privilege=user arch=x86_64 libc=glibc node=v[0-9]+\.[0-9]+\.[0-9]+ npm=[0-9]+\.[0-9]+\.[0-9]+
|
||||||
|
next-git-present require ^\[SECRET-CONTROL\] PASS:
|
||||||
|
next-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$
|
||||||
|
next-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:
|
||||||
|
next-git-present require ^\[P6\] FAIL: runtime linking/activation action reported a required failure$
|
||||||
|
next-git-present forbid Done\.|MOSAIC_C1_CANARY_|CLI not found on PATH
|
||||||
|
main-git-present exit 1
|
||||||
|
main-git-present phase P0=PASS
|
||||||
|
main-git-present phase P1=PASS
|
||||||
|
main-git-present phase P2=PASS
|
||||||
|
main-git-present phase P3=PASS
|
||||||
|
main-git-present phase P4=FAIL
|
||||||
|
main-git-present phase P5=FAIL
|
||||||
|
main-git-present phase P6=FAIL
|
||||||
|
main-git-present phase P7=PASS
|
||||||
|
main-git-present phase P8=FAIL
|
||||||
|
main-git-present phase P9=FAIL
|
||||||
|
main-git-present require ^\[fixture\] resolved lane=main .*version=[0-9]+\.[0-9]+\.[0-9]+$
|
||||||
|
main-git-present require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||||
|
main-git-present phase-reason P0=target=mosaic uid=1001 HOME=/home/mosaic passwd_HOME=/home/mosaic shell=/bin/bash privilege=user arch=x86_64 libc=glibc node=v[0-9]+\.[0-9]+\.[0-9]+ npm=[0-9]+\.[0-9]+\.[0-9]+
|
||||||
|
main-git-present require ^\[SECRET-CONTROL\] PASS:
|
||||||
|
main-git-present require ^\[P3\] PASS: absolute_path=.* version=.* equals resolved lane version$
|
||||||
|
main-git-present require ^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:
|
||||||
|
main-git-present require ^\[P6\] FAIL: runtime linking/activation action reported a required failure
|
||||||
|
main-git-present forbid Done\.|MOSAIC_C1_CANARY_|CLI not found on PATH
|
||||||
|
next-git-absent exit 1
|
||||||
|
next-git-absent phase P0=PASS
|
||||||
|
next-git-absent phase P1=FAIL
|
||||||
|
next-git-absent phase P2=FAIL
|
||||||
|
next-git-absent phase P3=FAIL
|
||||||
|
next-git-absent phase P4=FAIL
|
||||||
|
next-git-absent phase P5=FAIL
|
||||||
|
next-git-absent phase P6=PASS
|
||||||
|
next-git-absent phase P7=PASS
|
||||||
|
next-git-absent phase P8=FAIL
|
||||||
|
next-git-absent phase P9=FAIL
|
||||||
|
next-git-absent require ^\[fixture\] installer_exit=1 done_claims=0$
|
||||||
|
next-git-absent phase-reason P0=target=mosaic uid=1001 HOME=/home/mosaic passwd_HOME=/home/mosaic shell=/bin/bash privilege=user arch=x86_64 libc=glibc node=v[0-9]+\.[0-9]+\.[0-9]+ npm=[0-9]+\.[0-9]+\.[0-9]+
|
||||||
|
next-git-absent require ^\[SECRET-CONTROL\] PASS:
|
||||||
|
next-git-absent require ^\[P1\] FAIL: undeclared/missing prerequisite\(s\)=git;
|
||||||
|
next-git-absent require ^\[P3\] FAIL: .*executable=no
|
||||||
|
next-git-absent forbid Done\.|MOSAIC_C1_CANARY_
|
||||||
|
Executable
+635
@@ -0,0 +1,635 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=tools/test-enumeration-assertions.sh
|
||||||
|
source "$ROOT/tools/test-enumeration-assertions.sh"
|
||||||
|
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
export TMPDIR="$TMP/runtime-tmp"
|
||||||
|
mkdir -p "$TMPDIR"
|
||||||
|
|
||||||
|
FAKE_BIN="$TMP/bin"
|
||||||
|
HOME_DIR="$TMP/home"
|
||||||
|
PREFIX="$HOME_DIR/prefix"
|
||||||
|
MOSAIC_HOME="$HOME_DIR/mosaic"
|
||||||
|
STATE="$TMP/state"
|
||||||
|
LOG="$TMP/npm.log"
|
||||||
|
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
|
||||||
|
|
||||||
|
if [[ "${MOSAIC_TEST_ENUMERATION_FAILURE_CHILD:-0}" != "1" ]]; then
|
||||||
|
printf '[test] enumeration failure cannot mask a planted in-progress transaction\n'
|
||||||
|
set +e
|
||||||
|
if [[ "$(/usr/bin/id -u)" -eq 0 ]]; then
|
||||||
|
enumeration_control_output="$(
|
||||||
|
su -s /bin/bash nobody -c \
|
||||||
|
"TMPDIR=/tmp MOSAIC_TEST_ENUMERATION_FAILURE_CHILD=1 bash '$0'" 2>&1
|
||||||
|
)"
|
||||||
|
enumeration_control_status=$?
|
||||||
|
else
|
||||||
|
enumeration_control_output="$(MOSAIC_TEST_ENUMERATION_FAILURE_CHILD=1 bash "$0" 2>&1)"
|
||||||
|
enumeration_control_status=$?
|
||||||
|
fi
|
||||||
|
set -e
|
||||||
|
printf '%s\n' "$enumeration_control_output" > "$TMP/enumeration-failure-control.log"
|
||||||
|
if [[ "$enumeration_control_status" -eq 0 ]]; then
|
||||||
|
echo '[test] FAIL: planted in-progress transaction plus failed enumeration passed the full suite' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -qF '[test] ERROR: P2 fault-state enumeration failed' "$TMP/enumeration-failure-control.log" || {
|
||||||
|
echo '[test] FAIL: failed fault-state enumeration was not named' >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Model the supported non-root/glibc target explicitly even when this harness
|
||||||
|
# itself runs as root in Alpine/BusyBox CI.
|
||||||
|
cat > "$FAKE_BIN/id" <<'FAKE_ID'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
case "${1:-}" in
|
||||||
|
-u) echo 1001 ;;
|
||||||
|
-g) echo 1001 ;;
|
||||||
|
-un) echo fixture-user ;;
|
||||||
|
*) exec /bin/id "$@" ;;
|
||||||
|
esac
|
||||||
|
FAKE_ID
|
||||||
|
cat > "$FAKE_BIN/getent" <<FAKE_GETENT
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'fixture-user:x:1001:1001::%s:/bin/bash\n' '$HOME_DIR'
|
||||||
|
FAKE_GETENT
|
||||||
|
cat > "$FAKE_BIN/ldd" <<'FAKE_LDD'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'ldd (GNU libc) 2.36\n'
|
||||||
|
FAKE_LDD
|
||||||
|
cat > "$FAKE_BIN/stat" <<'FAKE_STAT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
if [[ "${1:-} ${2:-}" == '-c %u' ]]; then
|
||||||
|
[[ "${3:-}" == "${MOSAIC_TEST_WRONG_OWNER_PATH:-__none__}" ]] && echo 9999 || echo 1001
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-} ${2:-}" == '-c %g' ]]; then
|
||||||
|
[[ "${3:-}" == "${MOSAIC_TEST_WRONG_GROUP_PATH:-__none__}" ]] && echo 9999 || echo 1001
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /bin/stat "$@"
|
||||||
|
FAKE_STAT
|
||||||
|
cat > "$FAKE_BIN/realpath" <<'FAKE_REALPATH'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import os, sys
|
||||||
|
args=sys.argv[1:]
|
||||||
|
mode=args.pop(0) if args and args[0] in ('-e','-m') else '-m'
|
||||||
|
if args and args[0]=='--': args.pop(0)
|
||||||
|
if len(args)!=1 or (mode=='-e' and not os.path.exists(args[0])): raise SystemExit(1)
|
||||||
|
print(os.path.realpath(args[0]))
|
||||||
|
FAKE_REALPATH
|
||||||
|
chmod 0755 "$FAKE_BIN/id" "$FAKE_BIN/getent" "$FAKE_BIN/ldd" "$FAKE_BIN/stat" "$FAKE_BIN/realpath"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
STATE="${MOSAIC_TEST_STATE:?}"
|
||||||
|
echo "$*" >> "$LOG"
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "--version" ]]; then
|
||||||
|
echo "10.6.2"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_cli() {
|
||||||
|
local version="$1"
|
||||||
|
echo "$version" > "$STATE/mosaic"
|
||||||
|
mkdir -p "${MOSAIC_PREFIX:?}/bin"
|
||||||
|
cat > "$MOSAIC_PREFIX/bin/mosaic" <<CLI
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "\${1:-}" == "wizard" ]]; then
|
||||||
|
printf 'wizard\n' >> "\${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
mkdir -p "\${MOSAIC_HOME:?}" "\${HOME:?}/.config/mosaic-gateway"
|
||||||
|
printf '# Soul\\n\\nConfigured.\\n' > "\$MOSAIC_HOME/SOUL.md"
|
||||||
|
printf '# User\\n\\nConfigured.\\n' > "\$MOSAIC_HOME/USER.md"
|
||||||
|
chmod 0600 "\$MOSAIC_HOME/SOUL.md" "\$MOSAIC_HOME/USER.md"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s\\n' '$version'
|
||||||
|
CLI
|
||||||
|
chmod +x "$MOSAIC_PREFIX/bin/mosaic"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "$1" == "view" ]]; then
|
||||||
|
if [[ "${MOSAIC_TEST_FAIL_NPM_VIEW:-0}" == "1" ]]; then
|
||||||
|
echo "forced registry metadata failure" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
case "$2 $3" in
|
||||||
|
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
|
||||||
|
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
|
||||||
|
"@mosaicstack/mosaic version") echo "0.0.48" ;;
|
||||||
|
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$1" == "install" ]]; then
|
||||||
|
if [[ -n "${MOSAIC_INSTALL_SECRET_CANARY:-}" ]]; then
|
||||||
|
printf 'registry diagnostic authToken=%s\n' "$MOSAIC_INSTALL_SECRET_CANARY"
|
||||||
|
printf 'urls=https://alice:p@[email protected]/a https://bob:pa:[email protected]/b https://carol:p%%[email protected]/c https://[email protected]/d https://user%%[email protected]/e\n'
|
||||||
|
printf 'Authorization: Basic QWxhZGRpbjpvcGVu\n//registry/:_auth=Ym9iOnNlY3JldA==\nCookie: session=abc123\nSet-Cookie: sid=xyz789\n'
|
||||||
|
printf '%s\n' "$MOSAIC_INSTALL_SECRET_CANARY" > "${MOSAIC_TEST_CANARY_OBSERVATION:?}"
|
||||||
|
fi
|
||||||
|
case "$*" in
|
||||||
|
*"@mosaicstack/[email protected]"*)
|
||||||
|
install_cli "0.0.49-next.999"
|
||||||
|
;;
|
||||||
|
*"@mosaicstack/[email protected]"*)
|
||||||
|
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
|
||||||
|
echo "forced gateway install failure" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "0.0.7-next.999" > "$STATE/gateway"
|
||||||
|
;;
|
||||||
|
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
|
||||||
|
install_cli "0.0.0-source"
|
||||||
|
;;
|
||||||
|
*"mosaicstack-gateway-0.0.0-source.tgz"*)
|
||||||
|
echo "0.0.0-source" > "$STATE/gateway"
|
||||||
|
;;
|
||||||
|
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$1" == "ls" ]]; then
|
||||||
|
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
|
||||||
|
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
|
||||||
|
node -e '
|
||||||
|
const cli = process.argv[1];
|
||||||
|
const gateway = process.argv[2];
|
||||||
|
const dependencies = {};
|
||||||
|
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
|
||||||
|
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
|
||||||
|
process.stdout.write(JSON.stringify({ dependencies }));
|
||||||
|
' "$cli" "$gateway"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "unexpected npm command: $*" >&2
|
||||||
|
exit 1
|
||||||
|
FAKE_NPM
|
||||||
|
chmod +x "$FAKE_BIN/npm"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
headers=""; output=""; url=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-D) headers="$2"; shift 2 ;;
|
||||||
|
-o) output="$2"; shift 2 ;;
|
||||||
|
--max-filesize) shift 2 ;;
|
||||||
|
-*) shift ;;
|
||||||
|
*) url="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
case "$url" in
|
||||||
|
*/api/v1/repos/mosaicstack/stack/commits?sha=*)
|
||||||
|
printf 'HTTP/1.1 200 OK\r\ncontent-type: application/json; charset=utf-8\r\n\r\n' > "$headers"
|
||||||
|
printf '[{"sha":"1111111111111111111111111111111111111111"}]\n' > "$output"
|
||||||
|
;;
|
||||||
|
*/archive/*.tar.gz)
|
||||||
|
if [[ "${MOSAIC_TEST_CORRUPT_ARCHIVE:-0}" == "1" ]]; then
|
||||||
|
printf 'not-a-tarball\n' > "$output"
|
||||||
|
else
|
||||||
|
archive_root="$(mktemp -d)"
|
||||||
|
mkdir -p "$archive_root/stack"
|
||||||
|
printf 'fixture\n' > "$archive_root/stack/.fixture"
|
||||||
|
/bin/tar czf "$output" -C "$archive_root" stack
|
||||||
|
rm -rf "$archive_root"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
FAKE_CURL
|
||||||
|
chmod +x "$FAKE_BIN/curl"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
dest=""; list=false
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-C) dest="$2"; shift 2 ;;
|
||||||
|
-*t*|t*) list=true; shift ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[[ "$list" == true ]] && exit 0
|
||||||
|
if [[ -z "$dest" ]]; then
|
||||||
|
echo "fake tar missing -C destination" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$dest/stack/packages/mosaic/framework" "$dest/stack/apps/gateway"
|
||||||
|
if [[ "${MOSAIC_TEST_EXTRA_ARCHIVE_ROOT:-0}" == "1" ]]; then
|
||||||
|
mkdir -p "$dest/unexpected-second-root"
|
||||||
|
fi
|
||||||
|
cat > "$dest/stack/packages/mosaic/framework/install.sh" <<'FRAMEWORK'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
expected="${MOSAIC_PREFIX:?}/bin/mosaic"
|
||||||
|
[[ "${MOSAIC_CLI_PATH:-}" == "$expected" && -x "$MOSAIC_CLI_PATH" ]] || {
|
||||||
|
echo "framework did not receive P3 absolute CLI (got=${MOSAIC_CLI_PATH:-unset} expected=$expected)" >&2
|
||||||
|
exit 61
|
||||||
|
}
|
||||||
|
printf 'framework-cli=%s version=%s\n' "$MOSAIC_CLI_PATH" "$($MOSAIC_CLI_PATH --version)" >> "${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
mkdir -p "${MOSAIC_HOME:?}/credentials"
|
||||||
|
chmod 0700 "$MOSAIC_HOME/credentials"
|
||||||
|
printf '# framework fixture\n' > "$MOSAIC_HOME/AGENTS.md"
|
||||||
|
FRAMEWORK
|
||||||
|
chmod 0755 "$dest/stack/packages/mosaic/framework/install.sh"
|
||||||
|
FAKE_TAR
|
||||||
|
chmod +x "$FAKE_BIN/tar"
|
||||||
|
|
||||||
|
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
LOG="${MOSAIC_TEST_NPM_LOG:?}"
|
||||||
|
echo "pnpm $*" >> "$LOG"
|
||||||
|
|
||||||
|
if [[ "$1" == "pack" ]]; then
|
||||||
|
out=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--pack-destination) out="$2"; shift 2 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [[ -z "$out" ]]; then
|
||||||
|
echo "fake pnpm pack missing destination" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
mkdir -p "$out"
|
||||||
|
case "$PWD" in
|
||||||
|
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
|
||||||
|
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
|
||||||
|
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${MOSAIC_TEST_FAIL_PNPM_INSTALL:-0}" == "1" && "$1" == "install" ]]; then
|
||||||
|
echo "forced pnpm install failure" >&2
|
||||||
|
exit 42
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Other install/build commands are no-ops in this harness.
|
||||||
|
exit 0
|
||||||
|
FAKE_PNPM
|
||||||
|
chmod +x "$FAKE_BIN/pnpm"
|
||||||
|
|
||||||
|
reset_state() {
|
||||||
|
: > "$LOG"
|
||||||
|
rm -f "$STATE"/*
|
||||||
|
}
|
||||||
|
|
||||||
|
tree_fingerprint() {
|
||||||
|
local root="$1"
|
||||||
|
if [[ ! -d "$root" ]]; then printf 'ABSENT\n'; return; fi
|
||||||
|
python3 - "$root" <<'PY'
|
||||||
|
import hashlib, os, stat, sys
|
||||||
|
root=os.path.abspath(sys.argv[1]); rows=[]
|
||||||
|
for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
|
||||||
|
for name in dirs + files:
|
||||||
|
path=os.path.join(current,name); meta=os.lstat(path)
|
||||||
|
rel=os.path.relpath(path,root)
|
||||||
|
target=os.readlink(path) if stat.S_ISLNK(meta.st_mode) else ''
|
||||||
|
digest=''
|
||||||
|
if stat.S_ISREG(meta.st_mode):
|
||||||
|
with open(path,'rb') as handle: digest=hashlib.sha256(handle.read()).hexdigest()
|
||||||
|
rows.append((rel,stat.S_IFMT(meta.st_mode),stat.S_IMODE(meta.st_mode),target,digest))
|
||||||
|
payload='\n'.join('|'.join(map(str,row)) for row in sorted(rows)).encode()
|
||||||
|
print(hashlib.sha256(payload).hexdigest())
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix_fingerprint() { tree_fingerprint "$PREFIX"; }
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] --next fast path pins resolved package versions"
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||||
|
)"
|
||||||
|
|
||||||
|
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
|
||||||
|
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||||
|
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||||
|
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
|
||||||
|
echo "expected exact-version installs, found mutable @next install" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -qF 'Downloading source ref next at pinned commit' <<<"$OUTPUT"; then
|
||||||
|
echo "fast path unexpectedly fell back to source" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACTIVE="$HOME_DIR/.local/state/mosaic/install/active.json"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "committed" ]]
|
||||||
|
JOURNAL="$(node -p "require('$ACTIVE').journal")"
|
||||||
|
[[ "$(stat -c '%a' "$JOURNAL")" == "444" ]]
|
||||||
|
( cd "$(dirname "$JOURNAL")" && sha256sum -c "$(basename "$JOURNAL").sha256" >/dev/null )
|
||||||
|
grep -q '"event":"mutation".*"phase":"P3".*path=.*prior=.*reverse=' "$JOURNAL"
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] fast path failure falls back to source build"
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
|
||||||
|
)"
|
||||||
|
|
||||||
|
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Downloading source ref next at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||||
|
grep -qF 'install -g @mosaicstack/[email protected]' "$LOG"
|
||||||
|
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
|
||||||
|
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
|
||||||
|
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
|
||||||
|
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] source-build failure is fatal and restores the pre-install prefix"
|
||||||
|
before_prefix="$(prefix_fingerprint)"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
MOSAIC_TEST_FAIL_PNPM_INSTALL=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FAIL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||||
|
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||||
|
grep -qF 'forced pnpm install failure' <<<"$OUTPUT"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] corrupt source archive is fatal and restores the pre-install prefix"
|
||||||
|
before_prefix="$(prefix_fingerprint)"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
MOSAIC_TEST_CORRUPT_ARCHIVE=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FAIL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||||
|
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||||
|
grep -qF 'archive safety/integrity check failed' <<<"$OUTPUT"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] source archive with multiple extracted roots fails instead of selecting by find order"
|
||||||
|
before_prefix="$(prefix_fingerprint)"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
|
||||||
|
MOSAIC_TEST_EXTRA_ARCHIVE_ROOT=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FAIL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FAIL_STATUS" -ne 0 ]]
|
||||||
|
[[ "$(prefix_fingerprint)" == "$before_prefix" ]]
|
||||||
|
grep -qF 'expected exactly one extracted source root' <<<"$OUTPUT"
|
||||||
|
[[ "$(node -p "require('$ACTIVE').status")" == "rolled-back" ]]
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] --dev source install does not require registry version resolution"
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_FAIL_NPM_VIEW=1 \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --dev --ref feature-x --yes --no-auto-launch
|
||||||
|
)"
|
||||||
|
grep -qF 'Downloading source ref feature-x at pinned commit 1111111111111111111111111111111111111111' <<<"$OUTPUT"
|
||||||
|
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
|
||||||
|
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
|
||||||
|
)"
|
||||||
|
CHECK_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||||
|
grep -q '^\[P2\] PASS: source_ref=feature-x pinned_commit=1111111111111111111111111111111111111111 sha256=' <<<"$OUTPUT"
|
||||||
|
if grep -qF '@next version' "$LOG"; then
|
||||||
|
echo "explicit ref should not query @next dist-tags" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
reset_state
|
||||||
|
echo "[test] --check --next rejects mismatched prerelease pipeline suffixes"
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" \
|
||||||
|
MOSAIC_HOME="$MOSAIC_HOME" \
|
||||||
|
MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" \
|
||||||
|
MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
|
||||||
|
PATH="$FAKE_BIN:$PATH" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --cli --next
|
||||||
|
)"
|
||||||
|
CHECK_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$CHECK_STATUS" -ne 0 ]]
|
||||||
|
grep -q '^\[P2\] FAIL: resolved_version=unavailable' <<<"$OUTPUT"
|
||||||
|
|
||||||
|
printf '[test] full framework path receives P3 absolute CLI without relying on PATH\n'
|
||||||
|
rm -rf "$HOME_DIR" "$STATE"; mkdir -p "$HOME_DIR" "$STATE"; reset_state
|
||||||
|
set +e
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/full-state" MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
FULL_STATUS=$?
|
||||||
|
set -e
|
||||||
|
[[ "$FULL_STATUS" -ne 0 ]] # P4 remains intentionally undeclared until C5.
|
||||||
|
grep -qF "framework-cli=$PREFIX/bin/mosaic version=0.0.49-next.999" "$LOG"
|
||||||
|
if grep -q "CLI not found on PATH\|did not receive P3 absolute CLI" <<<"$OUTPUT"; then
|
||||||
|
echo "internal framework phase depended on PATH instead of P3 absolute CLI" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[test] captured diagnostics redact seeded credential canary everywhere\n'
|
||||||
|
rm -rf "$HOME_DIR" "$STATE"; mkdir -p "$HOME_DIR" "$STATE"; reset_state
|
||||||
|
canary='C1_SECRET_CANARY_7df4c2'
|
||||||
|
OUTPUT="$(
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/secret-state" MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_INSTALL_SECRET_CANARY="$canary" MOSAIC_TEST_CANARY_OBSERVATION="$TMP/canary-observed" \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch 2>&1
|
||||||
|
)"
|
||||||
|
if grep -qF "$canary" <<<"$OUTPUT"; then echo 'credential canary leaked to terminal output' >&2; exit 1; fi
|
||||||
|
if grep -Eq 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' <<<"$OUTPUT"; then
|
||||||
|
echo 'credentialed URL userinfo leaked to terminal output' >&2; exit 1
|
||||||
|
fi
|
||||||
|
for host in example.com example.net example.org example.dev example.io; do
|
||||||
|
grep -qF "https://[REDACTED]@$host" <<<"$OUTPUT" \
|
||||||
|
|| { echo "credentialed URL redaction control missing for $host" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
secret_active="$TMP/secret-state/active.json"
|
||||||
|
secret_journal="$(node -p "require('$secret_active').journal")"
|
||||||
|
secret_command_log="$(dirname "$secret_journal")/commands.log"
|
||||||
|
if grep -R -F "$canary" "$secret_command_log" "$HOME_DIR" 2>/dev/null; then
|
||||||
|
echo 'credential canary leaked to persistent installer output' >&2; exit 1
|
||||||
|
fi
|
||||||
|
if grep -E 'alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789' "$secret_command_log" >/dev/null; then
|
||||||
|
echo 'credentialed URL userinfo leaked to persistent installer output' >&2; exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$(cat "$TMP/canary-observed" 2>/dev/null || true)" != "$canary" ]]; then
|
||||||
|
echo 'credential canary positive control was not exercised' >&2; exit 1
|
||||||
|
fi
|
||||||
|
test_assert_find_empty 'redacted diagnostic staging files' \
|
||||||
|
"$TMPDIR" -maxdepth 1 -type f \
|
||||||
|
\( -name 'mosaic-phase-redacted.*' -o -name 'mosaic-post-redacted.*' \) || exit 1
|
||||||
|
|
||||||
|
printf '[test] framework nested capture redacts the same canary and URL variants\n'
|
||||||
|
framework_test_home="$TMP/framework-redact-home"
|
||||||
|
framework_target="$framework_test_home/.config/mosaic"
|
||||||
|
framework_cli="$TMP/framework-redact-cli"
|
||||||
|
framework_log="$TMP/framework-redact-commands.log"
|
||||||
|
framework_status="$TMP/framework-redact-status.tsv"
|
||||||
|
mkdir -p "$framework_test_home"; : > "$framework_log"; : > "$framework_status"
|
||||||
|
cat > "$framework_cli" <<'FRAMEWORK_CLI'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf 'nested authToken=%s\n' "${MOSAIC_INSTALL_SECRET_CANARY:?}"
|
||||||
|
printf 'nested=https://alice:p@[email protected]/a https://bob:pa:[email protected]/b https://carol:p%%[email protected]/c https://[email protected]/d https://user%%[email protected]/e\n'
|
||||||
|
printf 'Authorization: Basic QWxhZGRpbjpvcGVu\n//registry/:_auth=Ym9iOnNlY3JldA==\nCookie: session=abc123\nSet-Cookie: sid=xyz789\n'
|
||||||
|
exit 1
|
||||||
|
FRAMEWORK_CLI
|
||||||
|
chmod 0755 "$framework_cli"
|
||||||
|
set +e
|
||||||
|
FRAMEWORK_OUTPUT="$(
|
||||||
|
HOME="$framework_test_home" MOSAIC_HOME="$framework_target" MOSAIC_INSTALL_MODE=overwrite \
|
||||||
|
MOSAIC_CLI_PATH="$framework_cli" MOSAIC_INSTALL_SECRET_CANARY="$canary" \
|
||||||
|
MOSAIC_INSTALL_COMMAND_LOG="$framework_log" MOSAIC_INSTALL_PHASE_STATUS_FILE="$framework_status" \
|
||||||
|
MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1 MOSAIC_SKIP_SKILLS_SYNC=1 \
|
||||||
|
bash "$ROOT/packages/mosaic/framework/install.sh" 2>&1
|
||||||
|
)"
|
||||||
|
framework_install_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$framework_install_status" -eq 0 ]]
|
||||||
|
if grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" <<<"$FRAMEWORK_OUTPUT" \
|
||||||
|
|| grep -Eq "$canary|alice:p@ss|bob:pa:ss|carol:p%40ss|token@example|user%3Apass|QWxhZGRpbjpvcGVu|Ym9iOnNlY3JldA|session=abc123|sid=xyz789" "$framework_log"; then
|
||||||
|
echo 'framework nested capture leaked credential diagnostics' >&2; exit 1
|
||||||
|
fi
|
||||||
|
for host in example.com example.net example.org example.dev example.io; do
|
||||||
|
grep -qF "https://[REDACTED]@$host" "$framework_log" \
|
||||||
|
|| { echo "framework URL redaction control missing for $host" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '[test] real P2-P8 actions run under fault injection and restore actual surfaces\n'
|
||||||
|
for phase in P2 P3 P4 P5 P6 P7 P8; do
|
||||||
|
rm -rf "$HOME_DIR" "$STATE" "$TMP/fault-$phase"; mkdir -p "$HOME_DIR" "$STATE" "$TMP/fault-$phase"
|
||||||
|
printf 'operator-sentinel\n' > "$HOME_DIR/operator.txt"
|
||||||
|
reset_state
|
||||||
|
before="$(tree_fingerprint "$HOME_DIR")"
|
||||||
|
set +e
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/fault-$phase" MOSAIC_INSTALL_FAULT_AFTER="$phase" \
|
||||||
|
MOSAIC_INSTALL_SELF_TEST_ALLOW=1 MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --state-machine-self-test --next --yes \
|
||||||
|
>"$TMP/fault-$phase.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$status" -eq 97 ]] || { echo "$phase real fault expected 97, got $status" >&2; exit 1; }
|
||||||
|
[[ -s "$LOG" ]] || { echo "$phase fault never entered the real action path" >&2; exit 1; }
|
||||||
|
[[ "$(tree_fingerprint "$HOME_DIR")" == "$before" ]] || { echo "$phase real rollback mismatch" >&2; exit 1; }
|
||||||
|
grep -q "phase=$phase" "$TMP/fault-$phase.log"
|
||||||
|
if [[ "${MOSAIC_TEST_ENUMERATION_FAILURE_CHILD:-0}" == "1" && "$phase" == "P2" ]]; then
|
||||||
|
mkdir -p "$TMP/fault-$phase/blocked"
|
||||||
|
printf '{"status":"in-progress"}\n' > "$TMP/fault-$phase/blocked/planted-in-progress.json"
|
||||||
|
chmod 0666 "$TMP/fault-$phase/blocked/planted-in-progress.json"
|
||||||
|
chmod 0100 "$TMP/fault-$phase/blocked"
|
||||||
|
trap 'chmod 0700 "$TMP/fault-P2/blocked" 2>/dev/null || true; rm -rf "$TMP"' EXIT
|
||||||
|
fi
|
||||||
|
test_assert_no_file_content_match "$phase fault-state" \
|
||||||
|
'"status"[[:space:]]*:[[:space:]]*"in-progress"' "$TMP/fault-$phase" || exit 1
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '[test] stale projection is preserved while the real fault path acquires a free OS lock\n'
|
||||||
|
rm -rf "$HOME_DIR" "$STATE" "$TMP/stale-state"; mkdir -p "$HOME_DIR" "$STATE" "$TMP/stale-state"
|
||||||
|
printf '{"status":"in-progress","journal":"%s"}\n' "$TMP/stale-state/dead-run/journal.ndjson" > "$TMP/stale-state/active.json"
|
||||||
|
reset_state
|
||||||
|
set +e
|
||||||
|
HOME="$HOME_DIR" MOSAIC_HOME="$MOSAIC_HOME" MOSAIC_PREFIX="$PREFIX" \
|
||||||
|
MOSAIC_INSTALL_STATE_DIR="$TMP/stale-state" MOSAIC_INSTALL_FAULT_AFTER=P2 \
|
||||||
|
MOSAIC_INSTALL_SELF_TEST_ALLOW=1 MOSAIC_NO_COLOR=1 \
|
||||||
|
MOSAIC_TEST_NPM_LOG="$LOG" MOSAIC_TEST_STATE="$STATE" \
|
||||||
|
PATH="$FAKE_BIN:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --state-machine-self-test --next --yes >"$TMP/stale.log" 2>&1
|
||||||
|
stale_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$stale_status" -eq 97 ]]
|
||||||
|
find "$TMP/stale-state" -name prior-active.json -type f -print -quit | grep -q .
|
||||||
|
[[ "$(node -p "require('$TMP/stale-state/active.json').status")" == rolled-back ]]
|
||||||
|
|
||||||
|
echo "[test] installer next lane tests passed"
|
||||||
Executable
+431
@@ -0,0 +1,431 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Red-first acceptance checks for #1050. This file is committed before the
|
||||||
|
# installer implementation. Do not weaken these properties to make it green.
|
||||||
|
|
||||||
|
# pass_case always returns zero and fail_case records the aggregate failure;
|
||||||
|
# the compact A&&pass||fail assertions are intentional.
|
||||||
|
# shellcheck disable=SC2015
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=tools/test-enumeration-assertions.sh
|
||||||
|
source "$ROOT/tools/test-enumeration-assertions.sh"
|
||||||
|
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-state-test.XXXXXX")"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
failures=0
|
||||||
|
COMPAT_BIN="$TMP/compat-bin"
|
||||||
|
mkdir -p "$COMPAT_BIN"
|
||||||
|
cat > "$COMPAT_BIN/realpath" <<'REALPATH'
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
mode = args.pop(0) if args and args[0] in ("-e", "-m") else "-m"
|
||||||
|
if args and args[0] == "--":
|
||||||
|
args.pop(0)
|
||||||
|
if len(args) != 1 or (mode == "-e" and not os.path.exists(args[0])):
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(os.path.realpath(args[0]))
|
||||||
|
REALPATH
|
||||||
|
chmod 0755 "$COMPAT_BIN/realpath"
|
||||||
|
|
||||||
|
fail_case() { printf '[test] FAIL: %s\n' "$*" >&2; failures=$((failures + 1)); }
|
||||||
|
pass_case() { printf '[test] PASS: %s\n' "$*"; }
|
||||||
|
|
||||||
|
fingerprint() {
|
||||||
|
local dir="$1"
|
||||||
|
if [[ ! -d "$dir" ]]; then printf 'ABSENT\n'; return; fi
|
||||||
|
python3 - "$dir" <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = os.path.abspath(sys.argv[1])
|
||||||
|
rows = []
|
||||||
|
for current, dirs, files in os.walk(root, topdown=True, followlinks=False):
|
||||||
|
for name in dirs + files:
|
||||||
|
path = os.path.join(current, name)
|
||||||
|
rel = os.path.relpath(path, root)
|
||||||
|
meta = os.lstat(path)
|
||||||
|
target = os.readlink(path) if stat.S_ISLNK(meta.st_mode) else ""
|
||||||
|
digest = ""
|
||||||
|
if stat.S_ISREG(meta.st_mode):
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
digest = hashlib.sha256(handle.read()).hexdigest()
|
||||||
|
rows.append((rel, stat.S_IFMT(meta.st_mode), stat.S_IMODE(meta.st_mode), meta.st_uid, meta.st_gid, target, digest))
|
||||||
|
payload = "\n".join("|".join(map(str, row)) for row in sorted(rows)).encode()
|
||||||
|
print(hashlib.sha256(payload).hexdigest())
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
make_fake_npm() {
|
||||||
|
local bin="$1"
|
||||||
|
mkdir -p "$bin"
|
||||||
|
cat > "$bin/npm" <<'FAKE'
|
||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "${1:-}" == "--version" ]]; then echo '10.6.2'; exit 0; fi
|
||||||
|
case "${1:-} ${2:-} ${3:-}" in
|
||||||
|
'view @mosaicstack/mosaic@next version') echo '0.0.50-next.999' ;;
|
||||||
|
'view @mosaicstack/gateway@next version') echo '0.0.7-next.999' ;;
|
||||||
|
'view @mosaicstack/mosaic version') echo '0.0.49' ;;
|
||||||
|
'ls -g --depth=0'|'ls -g --json') echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;;
|
||||||
|
ls*) echo '{"dependencies":{"@mosaicstack/mosaic":{"version":"0.0.50-next.999"},"@mosaicstack/gateway":{"version":"0.0.7-next.999"}}}' ;;
|
||||||
|
*) echo "unexpected fake npm command: $*" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
FAKE
|
||||||
|
chmod 0755 "$bin/npm"
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '[test] case: --check enumerates exactly P0-P8, discriminates, and mutates nothing\n'
|
||||||
|
check_home="$TMP/check-home"
|
||||||
|
check_bin="$TMP/check-bin"
|
||||||
|
mkdir -p "$check_home/.config/mosaic/skills/alpha" "$check_home/.npm-global/bin" "$check_bin"
|
||||||
|
printf '# framework\n' > "$check_home/.config/mosaic/AGENTS.md"
|
||||||
|
printf '# skill\n' > "$check_home/.config/mosaic/skills/alpha/SKILL.md"
|
||||||
|
cat > "$check_home/.npm-global/bin/mosaic" <<'CLI'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '0.0.50-next.999\n'
|
||||||
|
CLI
|
||||||
|
chmod 0755 "$check_home/.npm-global/bin/mosaic"
|
||||||
|
make_fake_npm "$check_bin"
|
||||||
|
before="$(fingerprint "$check_home")"
|
||||||
|
set +e
|
||||||
|
HOME="$check_home" MOSAIC_HOME="$check_home/.config/mosaic" MOSAIC_PREFIX="$check_home/.npm-global" \
|
||||||
|
MOSAIC_NO_COLOR=1 PATH="$check_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/check.log" 2>&1
|
||||||
|
check_status=$?
|
||||||
|
set -e
|
||||||
|
after="$(fingerprint "$check_home")"
|
||||||
|
|
||||||
|
[[ "$before" == "$after" ]] && pass_case '--check left the complete HOME fingerprint unchanged' \
|
||||||
|
|| fail_case "--check mutated HOME (before=$before after=$after)"
|
||||||
|
[[ "$check_status" -ne 0 ]] && pass_case '--check exited non-zero for failed P4/P5/P8 predicates' \
|
||||||
|
|| fail_case '--check returned zero on the deliberately broken host'
|
||||||
|
|
||||||
|
phase_rows=0
|
||||||
|
for phase in P0 P1 P2 P3 P4 P5 P6 P7 P8; do
|
||||||
|
count="$(grep -Ec "^\[$phase\] (PASS|FAIL):" "$TMP/check.log" || true)"
|
||||||
|
[[ "$count" -eq 1 ]] || fail_case "$phase expected exactly one PASS/FAIL row, got $count"
|
||||||
|
phase_rows=$((phase_rows + count))
|
||||||
|
done
|
||||||
|
[[ "$phase_rows" -eq 9 ]] && pass_case '--check emitted exactly nine P0-P8 result rows' \
|
||||||
|
|| fail_case "--check emitted $phase_rows canonical rows instead of 9"
|
||||||
|
grep -q '^\[P3\] PASS:.*0\.0\.50-next\.999' "$TMP/check.log" \
|
||||||
|
&& pass_case 'P3 preserves the absolute-path exact-version discriminator' \
|
||||||
|
|| fail_case 'P3 did not PASS with the exact resolved next-lane version'
|
||||||
|
grep -q '^\[P4\] FAIL: NOT-MEASURED / UNDECLARED:' "$TMP/check.log" \
|
||||||
|
&& pass_case 'P4 refuses fabricated precision when no shipped-set declaration exists' \
|
||||||
|
|| fail_case 'P4 did not report the declared-set population as NOT-MEASURED / UNDECLARED'
|
||||||
|
for phase in P5 P8; do
|
||||||
|
grep -q "^\[$phase\] FAIL:" "$TMP/check.log" \
|
||||||
|
&& pass_case "$phase remains an attributable expected RED" \
|
||||||
|
|| fail_case "$phase did not report its own expected failure"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '[test] case: --check discriminates a constructed good host without mutation\n'
|
||||||
|
good_home="$TMP/good-home"
|
||||||
|
good_bin="$TMP/good-bin"
|
||||||
|
good_prefix="$good_home/.npm-global"
|
||||||
|
good_mosaic="$good_home/.config/mosaic"
|
||||||
|
mkdir -p "$good_bin" "$good_prefix/bin" "$good_mosaic/skills/declared-skill"
|
||||||
|
make_fake_npm "$good_bin"
|
||||||
|
cp "$COMPAT_BIN/realpath" "$good_bin/realpath"
|
||||||
|
cat > "$good_bin/id" <<'ID'
|
||||||
|
#!/bin/bash
|
||||||
|
uid="${MOSAIC_TEST_UID:-1001}"
|
||||||
|
gid="${MOSAIC_TEST_GID:-1001}"
|
||||||
|
user="${MOSAIC_TEST_USER:-fixture-user}"
|
||||||
|
case "${1:-}" in
|
||||||
|
-u) echo "$uid" ;;
|
||||||
|
-g) echo "$gid" ;;
|
||||||
|
-un) echo "$user" ;;
|
||||||
|
*) exec /bin/id "$@" ;;
|
||||||
|
esac
|
||||||
|
ID
|
||||||
|
cat > "$good_bin/stat" <<'STAT'
|
||||||
|
#!/bin/bash
|
||||||
|
if [[ "${1:-} ${2:-}" == '-c %u' ]]; then
|
||||||
|
[[ "${3:-}" == "${MOSAIC_TEST_WRONG_OWNER_PATH:-__none__}" ]] && echo 9999 || echo "${MOSAIC_TEST_UID:-1001}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-} ${2:-}" == '-c %g' ]]; then
|
||||||
|
[[ "${3:-}" == "${MOSAIC_TEST_WRONG_GROUP_PATH:-__none__}" ]] && echo 9999 || echo "${MOSAIC_TEST_GID:-1001}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /bin/stat "$@"
|
||||||
|
STAT
|
||||||
|
cat > "$good_bin/curl" <<'CURL'
|
||||||
|
#!/bin/bash
|
||||||
|
exit 0
|
||||||
|
CURL
|
||||||
|
cat > "$good_bin/ldd" <<'LDD'
|
||||||
|
#!/bin/bash
|
||||||
|
echo 'ldd (GNU libc) 2.36'
|
||||||
|
LDD
|
||||||
|
chmod 0755 "$good_bin/id" "$good_bin/stat" "$good_bin/curl" "$good_bin/ldd"
|
||||||
|
cat > "$good_prefix/bin/mosaic" <<'CLI'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '0.0.50-next.999\n'
|
||||||
|
CLI
|
||||||
|
chmod 0755 "$good_prefix/bin/mosaic"
|
||||||
|
cat > "$good_bin/getent" <<GETENT
|
||||||
|
#!/bin/bash
|
||||||
|
printf '%s:x:%s:%s::%s:%s\\n' "\${MOSAIC_TEST_USER:-fixture-user}" "\${MOSAIC_TEST_UID:-1001}" "\${MOSAIC_TEST_GID:-1001}" "\${MOSAIC_TEST_PASSWD_HOME:-$good_home}" '$good_bin/bash'
|
||||||
|
GETENT
|
||||||
|
cat > "$good_bin/bash" <<SHELL
|
||||||
|
#!/bin/bash
|
||||||
|
if [[ "\${*: -1}" == 'command -v mosaic' ]]; then
|
||||||
|
printf '%s\\n' '$good_prefix/bin/mosaic'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /bin/bash "\$@"
|
||||||
|
SHELL
|
||||||
|
chmod 0755 "$good_bin/getent" "$good_bin/bash"
|
||||||
|
printf '# Soul\n\nConfigured.\n' > "$good_mosaic/SOUL.md"
|
||||||
|
printf '# User\n\nConfigured.\n' > "$good_mosaic/USER.md"
|
||||||
|
chmod 0600 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md"
|
||||||
|
cat > "$good_mosaic/skills/declared-skill/SKILL.md" <<'SKILL'
|
||||||
|
---
|
||||||
|
name: declared-skill
|
||||||
|
description: Constructed loadable acceptance skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Declared skill
|
||||||
|
SKILL
|
||||||
|
printf '{"lane":"next","version":"0.0.50-next.999","skills":["declared-skill"]}\n' > "$good_mosaic/.install-shipped-skills.json"
|
||||||
|
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999"\n}\n' > "$good_mosaic/.install-manifest.json"
|
||||||
|
before="$(fingerprint "$good_home")"
|
||||||
|
set +e
|
||||||
|
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||||
|
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/good-check.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
after="$(fingerprint "$good_home")"
|
||||||
|
[[ "$status" -eq 0 ]] && pass_case 'good-host --check exited zero' || fail_case "good-host --check exited $status"
|
||||||
|
[[ "$before" == "$after" ]] && pass_case 'good-host --check left HOME unchanged' || fail_case 'good-host --check mutated HOME'
|
||||||
|
good_rows="$(grep -Ec '^\[P[0-8]\] PASS:' "$TMP/good-check.log" || true)"
|
||||||
|
[[ "$good_rows" -eq 9 ]] && pass_case 'good-host --check emitted nine PASS rows' \
|
||||||
|
|| { cat "$TMP/good-check.log" >&2; fail_case "good-host --check emitted $good_rows PASS rows"; }
|
||||||
|
|
||||||
|
printf '[test] case: P0 binds uid, username, passwd HOME, shell, and privilege mode\n'
|
||||||
|
passwd_home="$TMP/passwd-authoritative-home"
|
||||||
|
mkdir -p "$passwd_home"
|
||||||
|
set +e
|
||||||
|
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||||
|
MOSAIC_TEST_PASSWD_HOME="$passwd_home" MOSAIC_NO_COLOR=1 \
|
||||||
|
PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/wrong-home.log" 2>&1
|
||||||
|
wrong_home_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$wrong_home_status" -ne 0 ]] || fail_case 'P0 accepted ambient HOME that disagrees with passwd HOME'
|
||||||
|
grep -q '^\[P0\] FAIL:.*HOME mismatch' "$TMP/wrong-home.log" \
|
||||||
|
&& pass_case 'P0 rejects ambient HOME that disagrees with passwd HOME' \
|
||||||
|
|| fail_case 'P0 did not attribute the passwd HOME mismatch'
|
||||||
|
|
||||||
|
for privilege_case in root-with-home sudo-with-inherited-home; do
|
||||||
|
extra_env=()
|
||||||
|
[[ "$privilege_case" == sudo-with-inherited-home ]] && extra_env+=(SUDO_USER=fixture-user SUDO_UID=1001)
|
||||||
|
set +e
|
||||||
|
env HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||||
|
MOSAIC_TEST_UID=0 MOSAIC_TEST_GID=0 MOSAIC_TEST_USER=root MOSAIC_TEST_PASSWD_HOME=/root \
|
||||||
|
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" "${extra_env[@]}" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/$privilege_case.log" 2>&1
|
||||||
|
privilege_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$privilege_status" -ne 0 ]] || fail_case "P0 accepted unsafe $privilege_case context"
|
||||||
|
grep -q '^\[P0\] FAIL:.*privilege=' "$TMP/$privilege_case.log" \
|
||||||
|
&& pass_case "P0 states and rejects $privilege_case privilege context" \
|
||||||
|
|| fail_case "P0 did not state $privilege_case privilege mode"
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '[test] case: P3/P5 reject unsafe owner, group, and mode\n'
|
||||||
|
chmod 0777 "$good_prefix/bin/mosaic"
|
||||||
|
set +e
|
||||||
|
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \
|
||||||
|
PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" bash "$ROOT/tools/install.sh" --check --next >"$TMP/p3-mode.log" 2>&1
|
||||||
|
p3_mode_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$p3_mode_status" -ne 0 ]] || fail_case 'P3 accepted mode-0777 CLI'
|
||||||
|
grep -q '^\[P3\] FAIL:.*unsafe owner/group/mode' "$TMP/p3-mode.log" \
|
||||||
|
&& pass_case 'P3 rejects group/world-writable CLI' || fail_case 'P3 did not attribute unsafe CLI mode'
|
||||||
|
chmod 0755 "$good_prefix/bin/mosaic"
|
||||||
|
|
||||||
|
for ownership_case in owner group; do
|
||||||
|
wrong_env=()
|
||||||
|
[[ "$ownership_case" == owner ]] && wrong_env+=(MOSAIC_TEST_WRONG_OWNER_PATH="$good_prefix/bin/mosaic")
|
||||||
|
[[ "$ownership_case" == group ]] && wrong_env+=(MOSAIC_TEST_WRONG_GROUP_PATH="$good_prefix/bin/mosaic")
|
||||||
|
set +e
|
||||||
|
env HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \
|
||||||
|
PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" "${wrong_env[@]}" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/p3-$ownership_case.log" 2>&1
|
||||||
|
owner_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$owner_status" -ne 0 ]] || fail_case "P3 accepted wrong CLI $ownership_case"
|
||||||
|
grep -q '^\[P3\] FAIL:.*unsafe owner/group/mode' "$TMP/p3-$ownership_case.log" \
|
||||||
|
&& pass_case "P3 rejects wrong CLI $ownership_case" || fail_case "P3 did not attribute wrong CLI $ownership_case"
|
||||||
|
done
|
||||||
|
|
||||||
|
chmod 0644 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md"
|
||||||
|
set +e
|
||||||
|
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \
|
||||||
|
PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" bash "$ROOT/tools/install.sh" --check --next >"$TMP/p5-mode.log" 2>&1
|
||||||
|
p5_mode_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$p5_mode_status" -ne 0 ]] || fail_case 'P5 accepted world-readable identity files'
|
||||||
|
grep -q '^\[P5\] FAIL:' "$TMP/p5-mode.log" \
|
||||||
|
&& pass_case 'P5 rejects world-readable identity files' || fail_case 'P5 did not reject identity mode 0644'
|
||||||
|
chmod 0600 "$good_mosaic/SOUL.md" "$good_mosaic/USER.md"
|
||||||
|
|
||||||
|
mkdir -p "$good_mosaic/credentials"
|
||||||
|
chmod 0755 "$good_mosaic/credentials"
|
||||||
|
set +e
|
||||||
|
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \
|
||||||
|
PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" bash "$ROOT/tools/install.sh" --check --next >"$TMP/p5-credentials.log" 2>&1
|
||||||
|
credential_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$credential_status" -ne 0 ]] || fail_case 'P5 accepted mode-0755 credentials directory'
|
||||||
|
grep -q '^\[P5\] FAIL:.*credentials' "$TMP/p5-credentials.log" \
|
||||||
|
&& pass_case 'P5 rejects group/world-readable credential storage' \
|
||||||
|
|| fail_case 'P5 did not attribute unsafe credential directory mode'
|
||||||
|
chmod 0700 "$good_mosaic/credentials"
|
||||||
|
|
||||||
|
printf '# framework\n' > "$good_mosaic/AGENTS.md"
|
||||||
|
chmod 0666 "$good_mosaic/AGENTS.md"
|
||||||
|
set +e
|
||||||
|
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \
|
||||||
|
PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" bash "$ROOT/tools/install.sh" --check --next >"$TMP/p4-tree-mode.log" 2>&1
|
||||||
|
framework_mode_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$framework_mode_status" -ne 0 ]] || fail_case 'P4 accepted group/world-writable framework path'
|
||||||
|
grep -q '^\[P4\] FAIL:.*owner/mode policy' "$TMP/p4-tree-mode.log" \
|
||||||
|
&& pass_case 'P4 inventories and rejects unsafe created framework paths' \
|
||||||
|
|| fail_case 'P4 did not attribute unsafe created-path mode'
|
||||||
|
chmod 0644 "$good_mosaic/AGENTS.md"
|
||||||
|
|
||||||
|
printf '[test] case: P4 fails closed when created-path enumeration is incomplete\n'
|
||||||
|
real_find="$(command -v find)"
|
||||||
|
cat > "$good_bin/find" <<FIND
|
||||||
|
#!/bin/bash
|
||||||
|
if [[ "\${1:-}" == '$good_mosaic' && "\${2:-}" == '-xdev' && "\${3:-}" == '-print0' ]]; then
|
||||||
|
printf '%s\\0' '$good_mosaic'
|
||||||
|
exit 73
|
||||||
|
fi
|
||||||
|
exec '$real_find' "\$@"
|
||||||
|
FIND
|
||||||
|
chmod 0755 "$good_bin/find"
|
||||||
|
printf '# hidden unsafe child\n' > "$good_mosaic/AGENTS.md"
|
||||||
|
chmod 0666 "$good_mosaic/AGENTS.md"
|
||||||
|
set +e
|
||||||
|
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" MOSAIC_NO_COLOR=1 \
|
||||||
|
PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" bash "$ROOT/tools/install.sh" --check --next \
|
||||||
|
>"$TMP/p4-enumeration-failure.log" 2>&1
|
||||||
|
p4_enumeration_status=$?
|
||||||
|
set -e
|
||||||
|
rm -f "$good_bin/find"
|
||||||
|
[[ "$p4_enumeration_status" -ne 0 ]] \
|
||||||
|
&& pass_case 'P4 rejects an incomplete created-path inventory' \
|
||||||
|
|| fail_case 'P4 accepted a partial created-path inventory after find failed'
|
||||||
|
grep -q '^\[P4\] FAIL:.*enumeration failed' "$TMP/p4-enumeration-failure.log" \
|
||||||
|
&& pass_case 'P4 attributes the failed created-path enumeration' \
|
||||||
|
|| fail_case 'P4 did not report failed created-path enumeration'
|
||||||
|
chmod 0644 "$good_mosaic/AGENTS.md"
|
||||||
|
|
||||||
|
printf '[test] case: persisted required-action failures remain blocking\n'
|
||||||
|
for blocked_phase in P4 P6; do
|
||||||
|
node -e '
|
||||||
|
const fs=require("fs"); const p=process.argv[1]; const phase=process.argv[2];
|
||||||
|
const m=JSON.parse(fs.readFileSync(p,"utf8")); m.phaseOutcomes={P4:"committed",P6:"committed"};
|
||||||
|
m.phaseOutcomes[phase]="failed"; fs.writeFileSync(p,JSON.stringify(m)+"\n");
|
||||||
|
' "$good_mosaic/.install-manifest.json" "$blocked_phase"
|
||||||
|
set +e
|
||||||
|
HOME="$good_home" MOSAIC_HOME="$good_mosaic" MOSAIC_PREFIX="$good_prefix" \
|
||||||
|
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/action-$blocked_phase.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$status" -ne 0 ]] || fail_case "$blocked_phase action failure returned zero"
|
||||||
|
grep -q "^\[$blocked_phase\] FAIL:.*action reported a required $blocked_phase failure" "$TMP/action-$blocked_phase.log" \
|
||||||
|
&& pass_case "$blocked_phase action failure remained blocking in a later --check" \
|
||||||
|
|| fail_case "$blocked_phase persisted action failure was not attributed"
|
||||||
|
done
|
||||||
|
printf '{\n "lane": "next",\n "cliVersion": "0.0.50-next.999",\n "phaseOutcomes": {"P4":"committed","P6":"committed"}\n}\n' > "$good_mosaic/.install-manifest.json"
|
||||||
|
|
||||||
|
printf '[test] case: fault injection has no synthetic mutation implementation\n'
|
||||||
|
if grep -q '\.selftest-' "$ROOT/tools/install.sh"; then
|
||||||
|
fail_case 'synthetic .selftest mutation path remains in the production fault seam'
|
||||||
|
else
|
||||||
|
pass_case 'fault seam is attached only to real P2-P8 action flow (exercised by install-next-lane.test.sh)'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[test] case: unsafe and overlapping rollback roots fail before mutation\n'
|
||||||
|
unsafe_home="$TMP/unsafe-home"
|
||||||
|
mkdir -p "$unsafe_home"
|
||||||
|
for case_name in root-target home-target overlap-target; do
|
||||||
|
case "$case_name" in
|
||||||
|
root-target) unsafe_mosaic=/; unsafe_prefix="$unsafe_home/.npm-global" ;;
|
||||||
|
home-target) unsafe_mosaic="$unsafe_home"; unsafe_prefix="$unsafe_home/.npm-global" ;;
|
||||||
|
overlap-target) unsafe_mosaic="$unsafe_home/.config"; unsafe_prefix="$unsafe_home/.config/mosaic/prefix" ;;
|
||||||
|
esac
|
||||||
|
before="$(fingerprint "$unsafe_home")"
|
||||||
|
set +e
|
||||||
|
HOME="$unsafe_home" MOSAIC_HOME="$unsafe_mosaic" MOSAIC_PREFIX="$unsafe_prefix" \
|
||||||
|
MOSAIC_TEST_PASSWD_HOME="$unsafe_home" MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/$case_name.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
after="$(fingerprint "$unsafe_home")"
|
||||||
|
[[ "$status" -ne 0 ]] || fail_case "$case_name unsafe path returned zero"
|
||||||
|
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/$case_name.log" \
|
||||||
|
&& pass_case "$case_name was rejected by P0" || fail_case "$case_name lacked an attributable P0 failure"
|
||||||
|
[[ "$before" == "$after" ]] || fail_case "$case_name mutated HOME"
|
||||||
|
done
|
||||||
|
|
||||||
|
symlink_home="$TMP/symlink-home"
|
||||||
|
symlink_outside="$TMP/symlink-outside"
|
||||||
|
mkdir -p "$symlink_home" "$symlink_outside"
|
||||||
|
ln -s "$symlink_outside" "$symlink_home/.config"
|
||||||
|
set +e
|
||||||
|
HOME="$symlink_home" MOSAIC_HOME="$symlink_home/.config/mosaic" MOSAIC_PREFIX="$symlink_home/.npm-global" \
|
||||||
|
MOSAIC_TEST_PASSWD_HOME="$symlink_home" MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --check --next >"$TMP/symlink-target.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$status" -ne 0 ]] || fail_case 'symlink-parent unsafe path returned zero'
|
||||||
|
grep -q '^\[P0\] FAIL:.*unsafe context' "$TMP/symlink-target.log" \
|
||||||
|
&& pass_case 'symlinked rollback parent was rejected by P0' \
|
||||||
|
|| fail_case 'symlinked rollback parent lacked an attributable P0 failure'
|
||||||
|
test_assert_find_empty 'symlink target mutation check' "$symlink_outside" -mindepth 1 \
|
||||||
|
|| fail_case 'symlink target was mutated or could not be enumerated'
|
||||||
|
|
||||||
|
printf '[test] case: journal initialization failure is fatal before mutation\n'
|
||||||
|
journal_home="$TMP/journal-failure/home"
|
||||||
|
mkdir -p "$journal_home/.config/mosaic"
|
||||||
|
printf 'journal-sentinel\n' > "$journal_home/.config/mosaic/operator.txt"
|
||||||
|
before="$(fingerprint "$journal_home")"
|
||||||
|
set +e
|
||||||
|
HOME="$journal_home" MOSAIC_HOME="$journal_home/.config/mosaic" MOSAIC_PREFIX="$journal_home/.npm-global" \
|
||||||
|
MOSAIC_TEST_PASSWD_HOME="$journal_home" MOSAIC_INSTALL_STATE_DIR="/proc/mosaic-journal-denied-$$" \
|
||||||
|
MOSAIC_NO_COLOR=1 PATH="$good_bin:/usr/local/bin:/usr/bin:/bin" \
|
||||||
|
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch \
|
||||||
|
>"$TMP/journal-failure.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
after="$(fingerprint "$journal_home")"
|
||||||
|
[[ "$status" -ne 0 ]] && pass_case 'unwritable journal directory failed non-zero' \
|
||||||
|
|| fail_case 'unwritable journal directory returned zero'
|
||||||
|
grep -q 'cannot create private journal directory' "$TMP/journal-failure.log" \
|
||||||
|
&& pass_case 'journal initialization failure was named' \
|
||||||
|
|| fail_case 'journal initialization failure lacked a named diagnostic'
|
||||||
|
[[ "$before" == "$after" ]] && pass_case 'journal failure occurred before target mutation' \
|
||||||
|
|| fail_case "journal failure mutated target HOME (before=$before after=$after)"
|
||||||
|
|
||||||
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
|
printf '[test] install state-machine acceptance RED: %d failed assertion(s)\n' "$failures" >&2
|
||||||
|
printf '[test] --check transcript: %s\n' "$TMP/check.log" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '[test] installer state-machine acceptance passed\n'
|
||||||
+1313
-73
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
e59cb441a2f37ae9150f8eae470238e9d858a1816df93343d9784a6796676096 install.sh
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fail-closed filesystem-enumeration assertions for shell test harnesses.
|
||||||
|
|
||||||
|
# Usage: test_assert_find_empty <label> <find arguments...>
|
||||||
|
test_assert_find_empty() {
|
||||||
|
local label="$1"
|
||||||
|
shift
|
||||||
|
local inventory
|
||||||
|
|
||||||
|
inventory="$(mktemp "${TMPDIR:-/tmp}/mosaic-test-find.XXXXXX")" || {
|
||||||
|
printf '[test] ERROR: %s inventory allocation failed\n' "$label" >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! find "$@" -print0 > "$inventory"; then
|
||||||
|
rm -f "$inventory"
|
||||||
|
printf '[test] ERROR: %s enumeration failed\n' "$label" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -s "$inventory" ]]; then
|
||||||
|
rm -f "$inventory"
|
||||||
|
printf '[test] FAIL: %s was not empty\n' "$label" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$inventory"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Usage: test_assert_no_file_content_match <label> <extended-regex> <find roots/options...>
|
||||||
|
test_assert_no_file_content_match() {
|
||||||
|
local label="$1"
|
||||||
|
local pattern="$2"
|
||||||
|
shift 2
|
||||||
|
local inventory path grep_status result=0
|
||||||
|
|
||||||
|
inventory="$(mktemp "${TMPDIR:-/tmp}/mosaic-test-find.XXXXXX")" || {
|
||||||
|
printf '[test] ERROR: %s inventory allocation failed\n' "$label" >&2
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! find "$@" -type f -print0 > "$inventory"; then
|
||||||
|
rm -f "$inventory"
|
||||||
|
printf '[test] ERROR: %s enumeration failed\n' "$label" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r -d '' path; do
|
||||||
|
grep_status=0
|
||||||
|
grep -Eq -- "$pattern" "$path" || grep_status=$?
|
||||||
|
if [[ "$grep_status" -eq 0 ]]; then
|
||||||
|
result=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [[ "$grep_status" -ne 1 ]]; then
|
||||||
|
result=2
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done < "$inventory"
|
||||||
|
|
||||||
|
rm -f "$inventory"
|
||||||
|
if [[ "$result" -eq 1 ]]; then
|
||||||
|
printf '[test] FAIL: %s contained a forbidden match\n' "$label" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if [[ "$result" -eq 2 ]]; then
|
||||||
|
printf '[test] ERROR: %s content inspection failed\n' "$label" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Fetch, authenticate, and execute the exact downloaded installer body.
|
||||||
|
set -euo pipefail
|
||||||
|
url="${1:?usage: verified-installer-fetch.sh <url> <sha256> [-- installer-args...]}"
|
||||||
|
expected="${2:?usage: verified-installer-fetch.sh <url> <sha256> [-- installer-args...]}"
|
||||||
|
shift 2
|
||||||
|
[[ "${1:-}" != -- ]] || shift
|
||||||
|
[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || { echo 'installer expected SHA-256 must be 64 lowercase hex characters' >&2; exit 2; }
|
||||||
|
tmp="$(mktemp "${TMPDIR:-/tmp}/mosaic-installer-body.XXXXXX")"
|
||||||
|
trap 'rm -f "$tmp"' EXIT
|
||||||
|
chmod 0600 "$tmp"
|
||||||
|
curl -fsSL "$url" -o "$tmp"
|
||||||
|
[[ -s "$tmp" ]] || { echo 'installer fetch returned an empty HTTP-success body' >&2; exit 1; }
|
||||||
|
actual="$(sha256sum "$tmp" | awk '{print $1}')"
|
||||||
|
[[ "$actual" == "$expected" ]] || { echo "installer SHA-256 mismatch (got=$actual expected=$expected)" >&2; exit 1; }
|
||||||
|
status=0
|
||||||
|
bash "$tmp" "$@" || status=$?
|
||||||
|
rm -f "$tmp"
|
||||||
|
trap - EXIT
|
||||||
|
exit "$status"
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
# shellcheck source=tools/test-enumeration-assertions.sh
|
||||||
|
source "$ROOT/tools/test-enumeration-assertions.sh"
|
||||||
|
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-fetch-contract.XXXXXX")"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
FAKE_BIN="$TMP/bin"; mkdir -p "$FAKE_BIN"
|
||||||
|
cat > "$FAKE_BIN/curl" <<'CURL'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
url=""; output=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-o) output="$2"; shift 2 ;;
|
||||||
|
-*) shift ;;
|
||||||
|
*) url="$1"; shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
emit() { if [[ -n "$output" ]]; then cat > "$output"; else cat; fi; }
|
||||||
|
case "$url" in
|
||||||
|
fixture://ok)
|
||||||
|
emit <<'SCRIPT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'executed:%s\n' "${1:-missing}"
|
||||||
|
SCRIPT
|
||||||
|
;;
|
||||||
|
fixture://empty) : > "$output" ;;
|
||||||
|
fixture://failed) exit 22 ;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
|
CURL
|
||||||
|
chmod 0755 "$FAKE_BIN/curl"
|
||||||
|
cat > "$TMP/ok.sh" <<'SCRIPT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'executed:%s\n' "${1:-missing}"
|
||||||
|
SCRIPT
|
||||||
|
ok_sha="$(sha256sum "$TMP/ok.sh" | awk '{print $1}')"
|
||||||
|
empty_sha="$(printf '' | sha256sum | awk '{print $1}')"
|
||||||
|
|
||||||
|
mkdir -p "$TMP/downloads"
|
||||||
|
output="$(TMPDIR="$TMP/downloads" PATH="$FAKE_BIN:$PATH" bash "$ROOT/tools/verified-installer-fetch.sh" fixture://ok "$ok_sha" -- marker)"
|
||||||
|
[[ "$output" == 'executed:marker' ]]
|
||||||
|
test_assert_find_empty 'verified-installer temporary downloads' "$TMP/downloads" -mindepth 1
|
||||||
|
printf '[test] PASS: digest-pinned fetched artifact executes and its temporary body is removed\n'
|
||||||
|
|
||||||
|
for row in 'fixture://empty empty-body' 'fixture://failed failed-fetch'; do
|
||||||
|
url="${row%% *}"; name="${row#* }"
|
||||||
|
set +e
|
||||||
|
PATH="$FAKE_BIN:$PATH" bash "$ROOT/tools/verified-installer-fetch.sh" "$url" "$empty_sha" -- marker \
|
||||||
|
>"$TMP/$name.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$status" -ne 0 ]] || { echo "[test] FAIL: $name certified success" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
printf '[test] PASS: failed fetch and HTTP-200 empty body are both rejected\n'
|
||||||
|
|
||||||
|
set +e
|
||||||
|
PATH="$FAKE_BIN:$PATH" bash "$ROOT/tools/verified-installer-fetch.sh" fixture://ok "${ok_sha/0/1}" -- marker \
|
||||||
|
>"$TMP/mismatch.log" 2>&1
|
||||||
|
status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$status" -ne 0 ]] || { echo '[test] FAIL: digest mismatch was accepted' >&2; exit 1; }
|
||||||
|
printf '[test] PASS: fetched installer digest mismatch is blocking\n'
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: $0 <coverage-checker> <case-manifest> <arm-manifest> <state-root> <run-id>" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ "$#" -eq 5 ]] || { usage; exit 2; }
|
||||||
|
checker="$1"
|
||||||
|
case_manifest="$2"
|
||||||
|
arm_manifest="$3"
|
||||||
|
state_root="$4"
|
||||||
|
run_id="$5"
|
||||||
|
|
||||||
|
[[ -f "$checker" ]] \
|
||||||
|
|| { echo "[fixture-suite] coverage checker missing: $checker" >&2; exit 2; }
|
||||||
|
|
||||||
|
set +e
|
||||||
|
bash "$checker" check cases "$case_manifest" "$state_root" "$run_id"
|
||||||
|
cases_status=$?
|
||||||
|
bash "$checker" check arms "$arm_manifest" "$state_root" "$run_id"
|
||||||
|
arms_status=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "$cases_status" -ne 0 ] || [ "$arms_status" -ne 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "usage: $0 <init|mark|check> <cases|arms> <expected-set> <state-root> <run-id> [name]" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ "$#" -ge 5 ]] || { usage; exit 2; }
|
||||||
|
mode="$1"
|
||||||
|
dimension="$2"
|
||||||
|
expected_set="$3"
|
||||||
|
state_root="$4"
|
||||||
|
run_id="$5"
|
||||||
|
name="${6:-}"
|
||||||
|
|
||||||
|
[[ "$dimension" == cases || "$dimension" == arms ]] \
|
||||||
|
|| { echo "[fixture-suite] invalid coverage dimension: $dimension" >&2; exit 2; }
|
||||||
|
[[ -s "$expected_set" ]] \
|
||||||
|
|| { echo "[fixture-suite] expected set missing or empty: $expected_set" >&2; exit 2; }
|
||||||
|
[[ -n "$state_root" && "$state_root" != / ]] \
|
||||||
|
|| { echo "[fixture-suite] unsafe state root" >&2; exit 2; }
|
||||||
|
[[ "$run_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|
||||||
|
|| { echo "[fixture-suite] invalid run id: $run_id" >&2; exit 2; }
|
||||||
|
|
||||||
|
singular="${dimension%s}"
|
||||||
|
run_dir="$state_root/$run_id/$dimension"
|
||||||
|
|
||||||
|
expected_names() {
|
||||||
|
awk -F '\t' 'NF && $0 !~ /^[[:space:]]*#/ && !seen[$1]++ { sub(/\r$/, "", $1); print $1 }' \
|
||||||
|
"$expected_set" | LC_ALL=C sort
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_expected_names() {
|
||||||
|
local found=0 expected
|
||||||
|
while IFS= read -r expected; do
|
||||||
|
[[ -n "$expected" ]] || continue
|
||||||
|
found=1
|
||||||
|
[[ "$expected" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|
||||||
|
|| { echo "[fixture-suite] invalid expected $singular name: $expected" >&2; return 1; }
|
||||||
|
done < <(expected_names)
|
||||||
|
[[ "$found" -eq 1 ]] \
|
||||||
|
|| { echo "[fixture-suite] expected set defines no $dimension" >&2; return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_expected_names
|
||||||
|
|
||||||
|
case "$mode" in
|
||||||
|
init)
|
||||||
|
[[ "$#" -eq 5 ]] || { usage; exit 2; }
|
||||||
|
mkdir -p "$run_dir"
|
||||||
|
find "$run_dir" -mindepth 1 -delete
|
||||||
|
;;
|
||||||
|
mark)
|
||||||
|
[[ "$#" -eq 6 ]] || { usage; exit 2; }
|
||||||
|
[[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \
|
||||||
|
|| { echo "[fixture-suite] invalid $singular marker name: $name" >&2; exit 2; }
|
||||||
|
[[ -d "$run_dir" ]] \
|
||||||
|
|| { echo "[fixture-suite] $dimension state was not initialized: $run_id" >&2; exit 1; }
|
||||||
|
if ! expected_snapshot="$(expected_names)"; then
|
||||||
|
echo "[fixture-suite] failed to read expected $dimension" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep -Fxq -- "$name" <<<"$expected_snapshot" \
|
||||||
|
|| { echo "[fixture-suite] $singular is not in the expected set: $name" >&2; exit 1; }
|
||||||
|
: > "$run_dir/$name.ran"
|
||||||
|
;;
|
||||||
|
check)
|
||||||
|
[[ "$#" -eq 5 ]] || { usage; exit 2; }
|
||||||
|
mkdir -p "$run_dir"
|
||||||
|
expected_file="$(mktemp "$run_dir/.expected.XXXXXX")"
|
||||||
|
actual_file="$(mktemp "$run_dir/.actual.XXXXXX")"
|
||||||
|
cleanup() { rm -f "$expected_file" "$actual_file"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
expected_names > "$expected_file"
|
||||||
|
[[ -r "$run_dir" && -x "$run_dir" ]] \
|
||||||
|
|| { echo "[fixture-suite] coverage state is not readable: $run_dir" >&2; exit 1; }
|
||||||
|
(
|
||||||
|
shopt -s nullglob dotglob
|
||||||
|
for marker in "$run_dir"/*.ran; do
|
||||||
|
[[ -f "$marker" && ! -L "$marker" ]] || continue
|
||||||
|
marker_name="${marker##*/}"
|
||||||
|
printf '%s\n' "${marker_name%.ran}"
|
||||||
|
done
|
||||||
|
) | LC_ALL=C sort -u > "$actual_file"
|
||||||
|
|
||||||
|
defined="$(wc -l < "$expected_file" | tr -d ' ')"
|
||||||
|
executed="$(wc -l < "$actual_file" | tr -d ' ')"
|
||||||
|
printf '[fixture-suite] %s_defined=%s %s_executed=%s\n' \
|
||||||
|
"$dimension" "$defined" "$dimension" "$executed"
|
||||||
|
|
||||||
|
if ! cmp -s "$expected_file" "$actual_file"; then
|
||||||
|
while IFS= read -r missing; do
|
||||||
|
[[ -n "$missing" ]] \
|
||||||
|
&& printf '[fixture-suite] missing_%s=%s\n' "$singular" "$missing" >&2
|
||||||
|
done < <(comm -23 "$expected_file" "$actual_file")
|
||||||
|
while IFS= read -r unexpected; do
|
||||||
|
[[ -n "$unexpected" ]] \
|
||||||
|
&& printf '[fixture-suite] unexpected_%s=%s\n' "$singular" "$unexpected" >&2
|
||||||
|
done < <(comm -13 "$expected_file" "$actual_file")
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
SUBJECT="${GREENFIELD_COVERAGE_SUBJECT:-$ROOT/tools/verify-greenfield-execution-coverage.sh}"
|
||||||
|
GATE="${GREENFIELD_COVERAGE_GATE:-$ROOT/tools/verify-greenfield-execution-coverage-gate.sh}"
|
||||||
|
WORKFLOW="$ROOT/.woodpecker/greenfield-install.yml"
|
||||||
|
INSTALLER="${GREENFIELD_E2E_INSTALLER:-$ROOT/tools/e2e-install-test.sh}"
|
||||||
|
mkdir -p "$ROOT/.mosaic-test-work"
|
||||||
|
TMP="$(mktemp -d "$ROOT/.mosaic-test-work/execution-coverage.XXXXXX")"
|
||||||
|
selector_control="$ROOT/greenfield-coverage-selector-control.ran"
|
||||||
|
cleanup() {
|
||||||
|
rm -f "$selector_control"
|
||||||
|
rm -rf "$TMP"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
manifest="$TMP/expected-red.tsv"
|
||||||
|
state_root="$TMP/state"
|
||||||
|
run_id="pipeline-1"
|
||||||
|
printf '%s\n' \
|
||||||
|
'# representative manifest comment' \
|
||||||
|
$'# case\tkind\tkey/value' \
|
||||||
|
'' \
|
||||||
|
$'next-git-present\texit\t1' \
|
||||||
|
$'main-git-present\texit\t1' \
|
||||||
|
$'next-git-absent\texit\t1' > "$manifest"
|
||||||
|
|
||||||
|
bash "$SUBJECT" init cases "$manifest" "$state_root" "$run_id"
|
||||||
|
|
||||||
|
# Positive firing control: a skipped expected case makes the final gate red.
|
||||||
|
bash "$SUBJECT" mark cases "$manifest" "$state_root" "$run_id" next-git-present
|
||||||
|
bash "$SUBJECT" mark cases "$manifest" "$state_root" "$run_id" main-git-present
|
||||||
|
set +e
|
||||||
|
skipped_output="$(bash "$SUBJECT" check cases "$manifest" "$state_root" "$run_id" 2>&1)"
|
||||||
|
skipped_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$skipped_status" -eq 1 ]]
|
||||||
|
grep -qF '[fixture-suite] cases_defined=3 cases_executed=2' <<<"$skipped_output"
|
||||||
|
grep -qF '[fixture-suite] missing_case=next-git-absent' <<<"$skipped_output"
|
||||||
|
|
||||||
|
# Count inflation cannot pass: equal counts with one missing and one unexpected remain red.
|
||||||
|
: > "$state_root/$run_id/cases/unexpected-case.ran"
|
||||||
|
set +e
|
||||||
|
inflated_output="$(bash "$SUBJECT" check cases "$manifest" "$state_root" "$run_id" 2>&1)"
|
||||||
|
inflated_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$inflated_status" -eq 1 ]]
|
||||||
|
grep -qF '[fixture-suite] cases_defined=3 cases_executed=3' <<<"$inflated_output"
|
||||||
|
grep -qF '[fixture-suite] missing_case=next-git-absent' <<<"$inflated_output"
|
||||||
|
grep -qF '[fixture-suite] unexpected_case=unexpected-case' <<<"$inflated_output"
|
||||||
|
|
||||||
|
# Re-initializing the same run clears stale markers instead of certifying a later run.
|
||||||
|
bash "$SUBJECT" init cases "$manifest" "$state_root" "$run_id"
|
||||||
|
set +e
|
||||||
|
stale_output="$(bash "$SUBJECT" check cases "$manifest" "$state_root" "$run_id" 2>&1)"
|
||||||
|
stale_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$stale_status" -eq 1 ]]
|
||||||
|
grep -qF '[fixture-suite] cases_defined=3 cases_executed=0' <<<"$stale_output"
|
||||||
|
|
||||||
|
# Exact case-set equality is green.
|
||||||
|
for case_name in next-git-present main-git-present next-git-absent; do
|
||||||
|
bash "$SUBJECT" mark cases "$manifest" "$state_root" "$run_id" "$case_name"
|
||||||
|
done
|
||||||
|
complete_output="$(bash "$SUBJECT" check cases "$manifest" "$state_root" "$run_id")"
|
||||||
|
grep -qF '[fixture-suite] cases_defined=3 cases_executed=3' <<<"$complete_output"
|
||||||
|
|
||||||
|
# A successful early match must consume the full producer and remain green under pipefail.
|
||||||
|
large_manifest="$TMP/large-expected.tsv"
|
||||||
|
awk 'BEGIN {
|
||||||
|
print "a-target\texit\t1"
|
||||||
|
for (i = 0; i < 20000; i++) printf "z-case-%05d\texit\t1\n", i
|
||||||
|
}' > "$large_manifest"
|
||||||
|
bash "$SUBJECT" init cases "$large_manifest" "$state_root" large-set
|
||||||
|
bash "$SUBJECT" mark cases "$large_manifest" "$state_root" large-set a-target
|
||||||
|
|
||||||
|
# The production manifest, including comments, initializes and checks.
|
||||||
|
production_manifest="$ROOT/tools/fixtures/greenfield-expected-red.tsv"
|
||||||
|
production_arms="$ROOT/tools/fixtures/greenfield-expected-arms.txt"
|
||||||
|
production_state="$TMP/production-state"
|
||||||
|
bash "$SUBJECT" init cases "$production_manifest" "$production_state" production-1
|
||||||
|
for case_name in next-git-present main-git-present next-git-absent; do
|
||||||
|
bash "$SUBJECT" mark cases "$production_manifest" "$production_state" production-1 "$case_name"
|
||||||
|
done
|
||||||
|
production_output="$(bash "$SUBJECT" check cases "$production_manifest" "$production_state" production-1)"
|
||||||
|
grep -qF '[fixture-suite] cases_defined=3 cases_executed=3' <<<"$production_output"
|
||||||
|
|
||||||
|
mark_all_cases() {
|
||||||
|
local root="$1" run="$2"
|
||||||
|
for case_name in next-git-present main-git-present next-git-absent; do
|
||||||
|
bash "$SUBJECT" mark cases "$production_manifest" "$root" "$run" "$case_name"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
mark_all_arms() {
|
||||||
|
local root="$1" run="$2"
|
||||||
|
for arm_name in \
|
||||||
|
greenfield-git-present \
|
||||||
|
greenfield-main-git-present \
|
||||||
|
greenfield-remote-installer-contract \
|
||||||
|
greenfield-git-absent; do
|
||||||
|
bash "$SUBJECT" mark arms "$production_arms" "$root" "$run" "$arm_name"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
gate_state="$TMP/gate-state"
|
||||||
|
gate_run="truth-table"
|
||||||
|
|
||||||
|
# Final aggregation truth table: cases PASS / arms FAIL is red.
|
||||||
|
bash "$SUBJECT" init cases "$production_manifest" "$gate_state" "$gate_run"
|
||||||
|
bash "$SUBJECT" init arms "$production_arms" "$gate_state" "$gate_run"
|
||||||
|
mark_all_cases "$gate_state" "$gate_run"
|
||||||
|
for arm_name in greenfield-git-present greenfield-main-git-present greenfield-git-absent; do
|
||||||
|
bash "$SUBJECT" mark arms "$production_arms" "$gate_state" "$gate_run" "$arm_name"
|
||||||
|
done
|
||||||
|
set +e
|
||||||
|
gate_cases_pass_output="$(bash "$GATE" "$SUBJECT" "$production_manifest" "$production_arms" "$gate_state" "$gate_run" 2>&1)"
|
||||||
|
gate_cases_pass_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$gate_cases_pass_status" -eq 1 ]]
|
||||||
|
grep -qF '[fixture-suite] cases_defined=3 cases_executed=3' <<<"$gate_cases_pass_output"
|
||||||
|
grep -qF '[fixture-suite] arms_defined=4 arms_executed=3' <<<"$gate_cases_pass_output"
|
||||||
|
grep -qF '[fixture-suite] missing_arm=greenfield-remote-installer-contract' <<<"$gate_cases_pass_output"
|
||||||
|
|
||||||
|
# Final aggregation truth table: cases FAIL / arms PASS is red.
|
||||||
|
bash "$SUBJECT" init cases "$production_manifest" "$gate_state" "$gate_run"
|
||||||
|
bash "$SUBJECT" init arms "$production_arms" "$gate_state" "$gate_run"
|
||||||
|
for case_name in next-git-present main-git-present; do
|
||||||
|
bash "$SUBJECT" mark cases "$production_manifest" "$gate_state" "$gate_run" "$case_name"
|
||||||
|
done
|
||||||
|
mark_all_arms "$gate_state" "$gate_run"
|
||||||
|
set +e
|
||||||
|
gate_arms_pass_output="$(bash "$GATE" "$SUBJECT" "$production_manifest" "$production_arms" "$gate_state" "$gate_run" 2>&1)"
|
||||||
|
gate_arms_pass_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$gate_arms_pass_status" -eq 1 ]]
|
||||||
|
grep -qF '[fixture-suite] cases_defined=3 cases_executed=2' <<<"$gate_arms_pass_output"
|
||||||
|
grep -qF '[fixture-suite] missing_case=next-git-absent' <<<"$gate_arms_pass_output"
|
||||||
|
grep -qF '[fixture-suite] arms_defined=4 arms_executed=4' <<<"$gate_arms_pass_output"
|
||||||
|
|
||||||
|
# Final aggregation truth table: both FAIL emits both failures and is red.
|
||||||
|
bash "$SUBJECT" init cases "$production_manifest" "$gate_state" "$gate_run"
|
||||||
|
bash "$SUBJECT" init arms "$production_arms" "$gate_state" "$gate_run"
|
||||||
|
for case_name in next-git-present main-git-present; do
|
||||||
|
bash "$SUBJECT" mark cases "$production_manifest" "$gate_state" "$gate_run" "$case_name"
|
||||||
|
done
|
||||||
|
for arm_name in greenfield-git-present greenfield-main-git-present greenfield-git-absent; do
|
||||||
|
bash "$SUBJECT" mark arms "$production_arms" "$gate_state" "$gate_run" "$arm_name"
|
||||||
|
done
|
||||||
|
set +e
|
||||||
|
gate_both_fail_output="$(bash "$GATE" "$SUBJECT" "$production_manifest" "$production_arms" "$gate_state" "$gate_run" 2>&1)"
|
||||||
|
gate_both_fail_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$gate_both_fail_status" -eq 1 ]]
|
||||||
|
grep -qF '[fixture-suite] missing_case=next-git-absent' <<<"$gate_both_fail_output"
|
||||||
|
grep -qF '[fixture-suite] missing_arm=greenfield-remote-installer-contract' <<<"$gate_both_fail_output"
|
||||||
|
|
||||||
|
# Final aggregation truth table: both PASS is green.
|
||||||
|
bash "$SUBJECT" init cases "$production_manifest" "$gate_state" "$gate_run"
|
||||||
|
bash "$SUBJECT" init arms "$production_arms" "$gate_state" "$gate_run"
|
||||||
|
mark_all_cases "$gate_state" "$gate_run"
|
||||||
|
mark_all_arms "$gate_state" "$gate_run"
|
||||||
|
gate_both_pass_output="$(bash "$GATE" "$SUBJECT" "$production_manifest" "$production_arms" "$gate_state" "$gate_run")"
|
||||||
|
grep -qF '[fixture-suite] cases_defined=3 cases_executed=3' <<<"$gate_both_pass_output"
|
||||||
|
grep -qF '[fixture-suite] arms_defined=4 arms_executed=4' <<<"$gate_both_pass_output"
|
||||||
|
|
||||||
|
# A future manifest case changes the derived set and cannot silently escape coverage.
|
||||||
|
printf '%s\n' $'future-case\texit\t1' >> "$manifest"
|
||||||
|
set +e
|
||||||
|
future_output="$(bash "$SUBJECT" check cases "$manifest" "$state_root" "$run_id" 2>&1)"
|
||||||
|
future_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$future_status" -eq 1 ]]
|
||||||
|
grep -qF '[fixture-suite] cases_defined=4 cases_executed=3' <<<"$future_output"
|
||||||
|
grep -qF '[fixture-suite] missing_case=future-case' <<<"$future_output"
|
||||||
|
|
||||||
|
# A skipped remote arm is red even while the three case identities are complete.
|
||||||
|
bash "$SUBJECT" init arms "$production_arms" "$production_state" production-1
|
||||||
|
for arm_name in greenfield-git-present greenfield-main-git-present greenfield-git-absent; do
|
||||||
|
bash "$SUBJECT" mark arms "$production_arms" "$production_state" production-1 "$arm_name"
|
||||||
|
done
|
||||||
|
set +e
|
||||||
|
remote_skipped_output="$(bash "$SUBJECT" check arms "$production_arms" "$production_state" production-1 2>&1)"
|
||||||
|
remote_skipped_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$remote_skipped_status" -eq 1 ]]
|
||||||
|
grep -qF '[fixture-suite] arms_defined=4 arms_executed=3' <<<"$remote_skipped_output"
|
||||||
|
grep -qF '[fixture-suite] missing_arm=greenfield-remote-installer-contract' <<<"$remote_skipped_output"
|
||||||
|
|
||||||
|
# Arm counts cannot be inflated either.
|
||||||
|
: > "$production_state/production-1/arms/unexpected-arm.ran"
|
||||||
|
set +e
|
||||||
|
arm_inflated_output="$(bash "$SUBJECT" check arms "$production_arms" "$production_state" production-1 2>&1)"
|
||||||
|
arm_inflated_status=$?
|
||||||
|
set -e
|
||||||
|
[[ "$arm_inflated_status" -eq 1 ]]
|
||||||
|
grep -qF '[fixture-suite] arms_defined=4 arms_executed=4' <<<"$arm_inflated_output"
|
||||||
|
grep -qF '[fixture-suite] missing_arm=greenfield-remote-installer-contract' <<<"$arm_inflated_output"
|
||||||
|
grep -qF '[fixture-suite] unexpected_arm=unexpected-arm' <<<"$arm_inflated_output"
|
||||||
|
|
||||||
|
# Exact arm-set equality is green.
|
||||||
|
bash "$SUBJECT" init arms "$production_arms" "$production_state" production-1
|
||||||
|
for arm_name in \
|
||||||
|
greenfield-git-present \
|
||||||
|
greenfield-main-git-present \
|
||||||
|
greenfield-remote-installer-contract \
|
||||||
|
greenfield-git-absent; do
|
||||||
|
bash "$SUBJECT" mark arms "$production_arms" "$production_state" production-1 "$arm_name"
|
||||||
|
done
|
||||||
|
arm_complete_output="$(bash "$SUBJECT" check arms "$production_arms" "$production_state" production-1)"
|
||||||
|
grep -qF '[fixture-suite] arms_defined=4 arms_executed=4' <<<"$arm_complete_output"
|
||||||
|
|
||||||
|
# Bind archive purity to the production checkout-archive selector before proving its effect.
|
||||||
|
python3 - "$INSTALLER" <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
text = Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||||
|
archive = re.search(
|
||||||
|
r'(?ms)^ tar -C "\$repo_parent" \\\n(?P<selectors>.*?)^ -czf "\$checkout_archive" "\$repo_name"$',
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
assert archive, "production checkout-archive command not found"
|
||||||
|
assert "--exclude='*/.mosaic-test-work'" in archive.group("selectors"), (
|
||||||
|
"production checkout archive does not exclude .mosaic-test-work"
|
||||||
|
)
|
||||||
|
PY
|
||||||
|
|
||||||
|
# The production-bound checkout-archive selector excludes coverage state under .mosaic-test-work.
|
||||||
|
: > "$selector_control"
|
||||||
|
archive="$TMP/checkout.tar.gz"
|
||||||
|
archive_list="$TMP/checkout.list"
|
||||||
|
repo_parent="$(dirname "$ROOT")"
|
||||||
|
repo_name="$(basename "$ROOT")"
|
||||||
|
tar -C "$repo_parent" \
|
||||||
|
--exclude='*/.git' --exclude='*/node_modules' --exclude='*/dist' \
|
||||||
|
--exclude='*/coverage' --exclude='*/.turbo' --exclude='*/.mosaic-test-work' \
|
||||||
|
--exclude='*/.env' --exclude='*/.env.*' \
|
||||||
|
-czf "$archive" "$repo_name"
|
||||||
|
tar -tzf "$archive" > "$archive_list"
|
||||||
|
grep -qF "$repo_name/greenfield-coverage-selector-control.ran" "$archive_list"
|
||||||
|
if grep -qF "$repo_name/.mosaic-test-work/" "$archive_list"; then
|
||||||
|
echo 'coverage state leaked into checkout archive' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Workflow contract: success-only case+arm markers, excluded state root, and complete ordering.
|
||||||
|
python3 - "$WORKFLOW" <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
text = Path(sys.argv[1]).read_text(encoding="utf-8")
|
||||||
|
steps = {
|
||||||
|
match.group(1): match.group(2)
|
||||||
|
for match in re.finditer(r"(?ms)^ ([A-Za-z0-9_-]+):\n(.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)", text)
|
||||||
|
}
|
||||||
|
case_steps = (
|
||||||
|
("greenfield-git-present", "next-git-present"),
|
||||||
|
("greenfield-main-git-present", "main-git-present"),
|
||||||
|
("greenfield-git-absent", "next-git-absent"),
|
||||||
|
)
|
||||||
|
for step, case in case_steps:
|
||||||
|
block = steps[step]
|
||||||
|
verify = block.index("bash tools/verify-greenfield-expected-red.sh")
|
||||||
|
mark = block.index(f'"$coverage_run" {case}')
|
||||||
|
assert verify < mark, f"{step} marks its case before successful verification"
|
||||||
|
|
||||||
|
arm_steps = (
|
||||||
|
"greenfield-git-present",
|
||||||
|
"greenfield-main-git-present",
|
||||||
|
"greenfield-remote-installer-contract",
|
||||||
|
"greenfield-git-absent",
|
||||||
|
)
|
||||||
|
for step in arm_steps:
|
||||||
|
block = steps[step]
|
||||||
|
verify = block.index("bash tools/verify-greenfield-expected-red.sh")
|
||||||
|
mark = block.index(f'"$coverage_run" {step}')
|
||||||
|
assert verify < mark, f"{step} marks its arm before successful verification"
|
||||||
|
assert re.search(
|
||||||
|
r"(?m)^ depends_on:\n - greenfield-case-denominator-init$", block
|
||||||
|
), f"{step} can race marker initialization"
|
||||||
|
|
||||||
|
assert ".greenfield-case-state" not in text
|
||||||
|
assert ".mosaic-test-work/greenfield-execution-coverage" in text
|
||||||
|
final = steps["greenfield-case-denominator"]
|
||||||
|
assert "status: [success, failure]" in final
|
||||||
|
assert "bash tools/verify-greenfield-execution-coverage-gate.sh" in final
|
||||||
|
assert "check cases" not in final and "check arms" not in final
|
||||||
|
assert "cases_status" not in final and "arms_status" not in final
|
||||||
|
for dependency in arm_steps:
|
||||||
|
assert f" - {dependency}\n" in final, f"final gate can race {dependency}"
|
||||||
|
PY
|
||||||
|
|
||||||
|
printf 'greenfield execution coverage tests passed\n'
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Verify that the detector found exactly the pinned C1 phase verdicts. The
|
||||||
|
# fixture is expected to exit non-zero; this verifier is the green CI contract.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
MANIFEST="${MOSAIC_EXPECTED_RED_MANIFEST:-$ROOT/tools/fixtures/greenfield-expected-red.tsv}"
|
||||||
|
CASE="${1:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||||
|
LOG="${2:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||||
|
FIXTURE_EXIT="${3:?usage: verify-greenfield-expected-red.sh <case> <log> <fixture-exit>}"
|
||||||
|
|
||||||
|
[[ -r "$MANIFEST" ]] || { echo "expected-RED manifest is unreadable: $MANIFEST" >&2; exit 2; }
|
||||||
|
[[ -r "$LOG" ]] || { echo "fixture log is unreadable: $LOG" >&2; exit 2; }
|
||||||
|
[[ "$FIXTURE_EXIT" =~ ^[0-9]+$ ]] || { echo "fixture exit is not numeric: $FIXTURE_EXIT" >&2; exit 2; }
|
||||||
|
|
||||||
|
# Validate the entire pinned contract before selecting one case. Otherwise a
|
||||||
|
# deleted case/phase silently disappears from the gate and a one-row manifest
|
||||||
|
# can certify any exit-1 transcript.
|
||||||
|
expected_cases=(next-git-present main-git-present next-git-absent)
|
||||||
|
declare -A allowed_case=(
|
||||||
|
[next-git-present]=1 [main-git-present]=1 [next-git-absent]=1
|
||||||
|
)
|
||||||
|
declare -A expected_requires=(
|
||||||
|
[next-git-present]=6 [main-git-present]=6 [next-git-absent]=4
|
||||||
|
)
|
||||||
|
declare -A row_count=() exit_count=() require_count=() forbid_count=() phase_count=() phase_reason_count=() unique_rows=()
|
||||||
|
while IFS= read -r raw; do
|
||||||
|
[[ -n "$raw" && "${raw:0:1}" != "#" ]] || continue
|
||||||
|
field_count="$(awk -F '\t' '{print NF}' <<<"$raw")"
|
||||||
|
[[ "$field_count" -eq 3 ]] || { echo "invalid expected-RED manifest row (expected exactly 3 tab fields): $raw" >&2; exit 2; }
|
||||||
|
IFS=$'\t' read -r case_name kind expectation <<<"$raw"
|
||||||
|
[[ -n "${allowed_case[$case_name]:-}" ]] || { echo "invalid expected-RED manifest case: $case_name" >&2; exit 2; }
|
||||||
|
unique_key="$case_name|$kind|$expectation"
|
||||||
|
[[ -z "${unique_rows[$unique_key]:-}" ]] || { echo "duplicate expected-RED manifest row: $raw" >&2; exit 2; }
|
||||||
|
unique_rows[$unique_key]=1
|
||||||
|
row_count[$case_name]=$((${row_count[$case_name]:-0} + 1))
|
||||||
|
case "$kind" in
|
||||||
|
exit)
|
||||||
|
[[ "$expectation" == 1 ]] || { echo "invalid expected-RED exit contract: case=$case_name expected=$expectation" >&2; exit 2; }
|
||||||
|
exit_count[$case_name]=$((${exit_count[$case_name]:-0} + 1))
|
||||||
|
;;
|
||||||
|
phase)
|
||||||
|
[[ "$expectation" =~ ^(P[0-9])=(PASS|FAIL)$ ]] \
|
||||||
|
|| { echo "invalid expected-RED phase disposition: case=$case_name value=$expectation" >&2; exit 2; }
|
||||||
|
phase="${BASH_REMATCH[1]}"
|
||||||
|
phase_key="$case_name|$phase"
|
||||||
|
phase_count[$phase_key]=$((${phase_count[$phase_key]:-0} + 1))
|
||||||
|
;;
|
||||||
|
phase-reason)
|
||||||
|
[[ "$expectation" =~ ^P0=(.+)$ ]] \
|
||||||
|
|| { echo "invalid expected-RED phase reason: case=$case_name value=$expectation" >&2; exit 2; }
|
||||||
|
phase_reason_count[$case_name|P0]=$((${phase_reason_count[$case_name|P0]:-0} + 1))
|
||||||
|
;;
|
||||||
|
require)
|
||||||
|
[[ -n "$expectation" ]] || { echo "empty expected-RED require row: case=$case_name" >&2; exit 2; }
|
||||||
|
require_count[$case_name]=$((${require_count[$case_name]:-0} + 1))
|
||||||
|
;;
|
||||||
|
forbid)
|
||||||
|
[[ -n "$expectation" ]] || { echo "empty expected-RED forbid row: case=$case_name" >&2; exit 2; }
|
||||||
|
forbid_count[$case_name]=$((${forbid_count[$case_name]:-0} + 1))
|
||||||
|
;;
|
||||||
|
*) echo "invalid expected-RED manifest kind: case=$case_name kind=$kind" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done < "$MANIFEST"
|
||||||
|
|
||||||
|
for case_name in "${expected_cases[@]}"; do
|
||||||
|
[[ "${exit_count[$case_name]:-0}" -eq 1 ]] \
|
||||||
|
|| { echo "expected-RED manifest requires exactly one exit row for case=$case_name" >&2; exit 2; }
|
||||||
|
for phase in P0 P1 P2 P3 P4 P5 P6 P7 P8 P9; do
|
||||||
|
[[ "${phase_count[$case_name|$phase]:-0}" -eq 1 ]] \
|
||||||
|
|| { echo "expected-RED manifest requires exactly one $phase disposition for case=$case_name" >&2; exit 2; }
|
||||||
|
done
|
||||||
|
[[ "${phase_reason_count[$case_name|P0]:-0}" -eq 1 ]] \
|
||||||
|
|| { echo "expected-RED manifest requires exactly one P0 reason binding for case=$case_name" >&2; exit 2; }
|
||||||
|
[[ "${require_count[$case_name]:-0}" -eq "${expected_requires[$case_name]}" ]] \
|
||||||
|
|| { echo "expected-RED manifest require-row population changed for case=$case_name" >&2; exit 2; }
|
||||||
|
[[ "${forbid_count[$case_name]:-0}" -eq 1 ]] \
|
||||||
|
|| { echo "expected-RED manifest requires exactly one forbid row for case=$case_name" >&2; exit 2; }
|
||||||
|
expected_total=$((1 + 10 + 1 + expected_requires[$case_name] + 1))
|
||||||
|
[[ "${row_count[$case_name]:-0}" -eq "$expected_total" ]] \
|
||||||
|
|| { echo "expected-RED manifest row population changed for case=$case_name" >&2; exit 2; }
|
||||||
|
done
|
||||||
|
[[ -n "${allowed_case[$CASE]:-}" ]] || { echo "unknown expected-RED verification case: $CASE" >&2; exit 2; }
|
||||||
|
|
||||||
|
checks=0
|
||||||
|
failures=0
|
||||||
|
while IFS=$'\t' read -r case_name kind expectation; do
|
||||||
|
[[ -n "$case_name" && "${case_name:0:1}" != "#" ]] || continue
|
||||||
|
[[ "$case_name" == "$CASE" ]] || continue
|
||||||
|
checks=$((checks + 1))
|
||||||
|
case "$kind" in
|
||||||
|
exit)
|
||||||
|
if [[ "$FIXTURE_EXIT" != "$expectation" ]]; then
|
||||||
|
echo "expected-RED mismatch: case=$CASE fixture_exit=$FIXTURE_EXIT expected=$expectation" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
phase)
|
||||||
|
phase="${expectation%%=*}"
|
||||||
|
expected_verdict="${expectation#*=}"
|
||||||
|
last_row="$(grep -E "^\[$phase\] (PASS|FAIL):" "$LOG" | tail -n 1 || true)"
|
||||||
|
actual_verdict="$(printf '%s\n' "$last_row" | sed -n "s/^\[$phase\] \(PASS\|FAIL\):.*/\1/p")"
|
||||||
|
if [[ "$actual_verdict" != "$expected_verdict" ]]; then
|
||||||
|
echo "expected-RED mismatch: case=$CASE phase=$phase got=${actual_verdict:-missing} expected=$expected_verdict" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
phase-reason)
|
||||||
|
phase="${expectation%%=*}"
|
||||||
|
expected_reason="${expectation#*=}"
|
||||||
|
last_row="$(grep -E "^\[$phase\] (PASS|FAIL):" "$LOG" | tail -n 1 || true)"
|
||||||
|
actual_reason="${last_row#*: }"
|
||||||
|
if [[ -z "$last_row" ]] || ! grep -Eq -- "^${expected_reason}$" <<<"$actual_reason"; then
|
||||||
|
echo "expected-RED phase reason mismatch: case=$CASE phase=$phase reason=${actual_reason:-missing}" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
require)
|
||||||
|
if ! grep -Eq -- "$expectation" "$LOG"; then
|
||||||
|
echo "expected-RED missing required evidence: case=$CASE regex=$expectation" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
forbid)
|
||||||
|
if grep -Eq -- "$expectation" "$LOG"; then
|
||||||
|
echo "expected-RED found forbidden evidence: case=$CASE regex=$expectation" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "invalid expected-RED manifest kind: case=$case_name kind=$kind" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < "$MANIFEST"
|
||||||
|
|
||||||
|
[[ "$checks" -gt 0 ]] || { echo "expected-RED manifest has no checks for case=$CASE" >&2; exit 2; }
|
||||||
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
|
echo "expected-RED verification failed: case=$CASE failures=$failures checks=$checks" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'expected-RED verification passed: case=%s checks=%d\n' "$CASE" "$checks"
|
||||||
Executable
+91
@@ -0,0 +1,91 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-expected-red-test.XXXXXX")"
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
cat > "$TMP/match.log" <<'LOG'
|
||||||
|
[fixture] resolved lane=next package=@mosaicstack/mosaic@next version=0.0.50-next.999
|
||||||
|
[fixture] installer_exit=1 done_claims=0
|
||||||
|
[SECRET-CONTROL] PASS: seeded canary absent from complete scan population
|
||||||
|
[P0] PASS: target=mosaic uid=1001 HOME=/home/mosaic passwd_HOME=/home/mosaic shell=/bin/bash privilege=user arch=x86_64 libc=glibc node=v24.0.0 npm=10.6.2
|
||||||
|
[P1] PASS: preflight complete
|
||||||
|
[P2] PASS: pinned artifact
|
||||||
|
[P3] PASS: absolute_path=/home/test/.npm-global/bin/mosaic version=0.0.50-next.999 equals resolved lane version
|
||||||
|
[P4] FAIL: NOT-MEASURED / UNDECLARED: declaration absent
|
||||||
|
[P5] FAIL: identity absent
|
||||||
|
[P6] FAIL: runtime linking/activation action reported a required failure
|
||||||
|
[P7] PASS: no services requested
|
||||||
|
[P8] FAIL: shell path absent
|
||||||
|
[P9] FAIL: aggregate refusal
|
||||||
|
LOG
|
||||||
|
|
||||||
|
bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null
|
||||||
|
printf '[test] PASS: matching detector findings make the CI verifier green\n'
|
||||||
|
|
||||||
|
sed 's/^\[P0\] PASS:.*/[P0] PASS: arbitrary unconditional success/' "$TMP/match.log" > "$TMP/vacuous-p0.log"
|
||||||
|
if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/vacuous-p0.log" 1 >/dev/null 2>&1; then
|
||||||
|
echo '[test] FAIL: vacuous P0 PASS satisfied the expected-RED contract without identity/context evidence' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '[test] PASS: P0 PASS must bind target identity, HOME, shell, privilege, architecture, and runtime reason\n'
|
||||||
|
|
||||||
|
sed 's/^\[P4\] FAIL:/[P4] PASS:/' "$TMP/match.log" > "$TMP/drift.log"
|
||||||
|
if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/drift.log" 1 >/dev/null 2>&1; then
|
||||||
|
echo '[test] FAIL: changed P4 verdict did not invalidate the pinned manifest' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '[test] PASS: changed phase verdict requires a deliberate manifest update\n'
|
||||||
|
|
||||||
|
if bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 0 >/dev/null 2>&1; then
|
||||||
|
echo '[test] FAIL: unexpected fixture exit did not invalidate the pinned manifest' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '[test] PASS: unexpected fixture exit remains blocking\n'
|
||||||
|
|
||||||
|
printf 'next-git-present\texit\t1\n' > "$TMP/shrunk.tsv"
|
||||||
|
if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/shrunk.tsv" \
|
||||||
|
bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then
|
||||||
|
echo '[test] FAIL: one-row manifest shrink still certified the detector' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '[test] PASS: manifest shrink cannot delete the structural contract\n'
|
||||||
|
|
||||||
|
manifest="$ROOT/tools/fixtures/greenfield-expected-red.tsv"
|
||||||
|
grep -v $'^next-git-present\tphase\tP8=' "$manifest" > "$TMP/missing-phase.tsv"
|
||||||
|
if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/missing-phase.tsv" \
|
||||||
|
bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then
|
||||||
|
echo '[test] FAIL: missing P8 disposition was accepted' >&2; exit 1
|
||||||
|
fi
|
||||||
|
printf '[test] PASS: every case requires one P0-P9 disposition\n'
|
||||||
|
|
||||||
|
grep -v $'^next-git-present\tphase-reason\tP0=' "$manifest" > "$TMP/missing-p0-reason.tsv"
|
||||||
|
if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/missing-p0-reason.tsv" \
|
||||||
|
bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then
|
||||||
|
echo '[test] FAIL: missing P0 reason binding was accepted' >&2; exit 1
|
||||||
|
fi
|
||||||
|
printf '[test] PASS: every case requires one discriminating P0 reason binding\n'
|
||||||
|
|
||||||
|
cp "$manifest" "$TMP/duplicate.tsv"
|
||||||
|
printf 'next-git-present\tphase\tP3=PASS\n' >> "$TMP/duplicate.tsv"
|
||||||
|
if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/duplicate.tsv" \
|
||||||
|
bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then
|
||||||
|
echo '[test] FAIL: duplicate phase key was accepted' >&2; exit 1
|
||||||
|
fi
|
||||||
|
printf '[test] PASS: duplicate structural keys are rejected\n'
|
||||||
|
|
||||||
|
cp "$manifest" "$TMP/unknown-case.tsv"
|
||||||
|
printf 'invented-case\texit\t1\n' >> "$TMP/unknown-case.tsv"
|
||||||
|
if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/unknown-case.tsv" \
|
||||||
|
bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then
|
||||||
|
echo '[test] FAIL: unknown case was accepted' >&2; exit 1
|
||||||
|
fi
|
||||||
|
printf '[test] PASS: unknown case rows are rejected\n'
|
||||||
|
|
||||||
|
cp "$manifest" "$TMP/unknown-kind.tsv"
|
||||||
|
printf 'next-git-present\toptional\tanything\n' >> "$TMP/unknown-kind.tsv"
|
||||||
|
if MOSAIC_EXPECTED_RED_MANIFEST="$TMP/unknown-kind.tsv" \
|
||||||
|
bash "$ROOT/tools/verify-greenfield-expected-red.sh" next-git-present "$TMP/match.log" 1 >/dev/null 2>&1; then
|
||||||
|
echo '[test] FAIL: unknown row kind was accepted' >&2; exit 1
|
||||||
|
fi
|
||||||
|
printf '[test] PASS: unknown manifest kinds are rejected\n'
|
||||||
Reference in New Issue
Block a user