Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f63340802c | ||
|
|
505b6f799c |
@@ -8,7 +8,6 @@ coverage
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
__pycache__/
|
||||
docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
pnpm typecheck && pnpm lint && pnpm format:check
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
||||
# Non-root checkouts use their own HOME. Override without editing this file via
|
||||
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
||||
store-dir=${HOME}/.local/share/pnpm/store
|
||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||
# instead of repopulating a fresh one.
|
||||
store-dir=/root/.local/share/pnpm/store
|
||||
|
||||
@@ -4,14 +4,6 @@ pnpm-lock.yaml
|
||||
**/node_modules
|
||||
**/drizzle
|
||||
**/.next
|
||||
# Python build/test artifacts — same category as node_modules/dist/.next above.
|
||||
# Prettier must never scan generated trees; without these a local venv poisons
|
||||
# `pnpm format:check` with thousands of third-party files.
|
||||
**/venv
|
||||
**/__pycache__
|
||||
**/.mypy_cache
|
||||
**/.pytest_cache
|
||||
**/htmlcov
|
||||
.claude/
|
||||
docs/tess/TASKS.md
|
||||
docs/scratchpads/
|
||||
|
||||
@@ -201,21 +201,8 @@ git clone [email protected]:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
||||
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
||||
pnpm install
|
||||
|
||||
# Verify dependencies and generated state before running source-quality gates.
|
||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
||||
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
||||
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
||||
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
||||
# trust anchor outside worktree authority.
|
||||
pnpm preflight
|
||||
|
||||
# Optional local queue service only. This does not start PostgreSQL.
|
||||
docker compose up -d valkey
|
||||
|
||||
@@ -243,7 +230,6 @@ Gateway start command until KBN-101-02 makes that state fail closed.
|
||||
### Quality Gates
|
||||
|
||||
```bash
|
||||
pnpm preflight # Checkout/dependency/generated-state validation
|
||||
pnpm typecheck # TypeScript type checking (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm test # Vitest (all packages)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
-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. 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. 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 register atomically, exact token scopes are read back from the provider token object, and rollback removes partial local registration.
|
||||
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.
|
||||
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, conflicting concurrent mutation, and production-store leakage into fixture tests. 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
|
||||
|
||||
### High-Level System Diagram
|
||||
|
||||
@@ -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`, `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 serializes governed mutations per provider team and 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; 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.
|
||||
@@ -1,25 +0,0 @@
|
||||
<!-- board-roll: 1 entry rolled from BOARD.md -->
|
||||
|
||||
### **D-1 / P-ACTIVATION + hygiene — committed `.npmrc` hard-pins `store-dir=/root/.local/share/pnpm/store`.**
|
||||
|
||||
Correct for the CI container (runs as root), fatal for EVERY non-root local checkout: `EACCES` on `/root/.local/share/pnpm/store/v10/server/server.json`. A committed config that only works on one runtime is exactly the activation-skew class. Fix candidate: make store-dir env-overridable, not hardcoded.
|
||||
|
||||
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||
|
||||
### **D-3 / P-FLEET-001 — the seats running this mission are UNMANAGED.** `mos-remediation`, `rev-974`,
|
||||
|
||||
`planner-opus`, `planner-sol` appear in NO roster (`~/.config/mosaic/fleet/roster.yaml`, `agents/`). Planners run on socket `default`; the roster declares `mosaic-fleet`. This is the exact "one roster-owned socket/host + quarantine unmanaged + stale GC" failure P-FLEET-001 indicts — observed on the remediation mission's own fleet. Prerequisite for INBOX identity-addressing.
|
||||
|
||||
### **D-2 / hygiene — husky `prepare` fails `EPERM` copying into root-owned `.husky/_/`.** Repo working
|
||||
|
||||
tree has root-owned dirs (`.husky/`, repo root) under a non-root agent. Worked around with the intended `HUSKY=0` escape hatch (does NOT disable the existing pre-commit/pre-push hooks).
|
||||
|
||||
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||
|
||||
### **D-5 / P-QUEUE-001 + P-CONFORMANCE-001 — KEYSTONE: an inert gate that erased its own evidence.**
|
||||
|
||||
Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ the CI format gate did not block. An unrelated later PR (#872) then reformatted that file via its own `lint-staged`, so `main` went green again and nobody learned the gate had failed to fire. Verified blob-level under the repo's own config. **Detection must be per-merge-commit against that commit's own tree** — a "is main green today" check reports all-clear on this exact defect. Binding on RM-02/RM-55. Full chain in `TASKS.md` §1a. NOT quiet-patched, by Mos's ruling: patching the symptom destroys the signal.
|
||||
|
||||
### **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.**
|
||||
|
||||
planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway. Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request.
|
||||
@@ -1,93 +0,0 @@
|
||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||
|
||||
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
|
||||
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||
|
||||
## Head
|
||||
|
||||
- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`).
|
||||
- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first.
|
||||
- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS.
|
||||
- **TASK-0 DONE** — checkout repaired, all three gates green HONESTLY (no `--no-verify`), branch pushed.
|
||||
- **TASK-1 DONE** — both planners delivered independently on clean context; reconciled into `TASKS.md`
|
||||
(58 tasks across P0–P5, 7 convergences, 7 adjudicated disagreements, 3 escalated decisions).
|
||||
- **NEXT ACTION IS NOT MINE:** DECISION-1/2/3 (`TASKS.md` §5) must be ruled before P0 dispatch.
|
||||
RM-01 is dispatchable immediately regardless — it depends on nothing and blocks everything.
|
||||
|
||||
## In-flight
|
||||
|
||||
| Task | Owner | State |
|
||||
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
||||
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
|
||||
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
|
||||
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
|
||||
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
|
||||
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
|
||||
|
||||
## Fleet seats
|
||||
|
||||
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
||||
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
||||
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
||||
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
||||
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
||||
|
||||
## Gate status
|
||||
|
||||
- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion.
|
||||
- Freeze: LIFTED for this workstream only.
|
||||
- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
||||
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
|
||||
- Capability check (D-11b): before dispatching seat X to provider Y, verify
|
||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists. Token-file set = authoritative
|
||||
capability registry. Mos owns provisioning; escalate missing pairs to him.
|
||||
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
|
||||
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
|
||||
- Standing worker-brief doctrine (accreted, mandatory in every brief): don't weaken a RED test to make
|
||||
it pass; if a check is unrunnable as written SAY SO, never silently substitute; `agent-send -f` never
|
||||
`-m`; heavy artifacts off shared `/tmp`.
|
||||
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
|
||||
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
||||
|
||||
## Sequencing (from MISSION.md)
|
||||
|
||||
1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis
|
||||
⚠ **CONTESTED — see DECISION-1.** Both planners independently reject this wire-in point: that
|
||||
controller is `"enabled": false` and references a dispatcher that does not exist here. Charter text
|
||||
left UNCHANGED pending Mos/Jason ruling; do not treat it as settled.
|
||||
2. Rotation daemon (finish Mission Control Plane, reuse packages/coord)
|
||||
3. Comms service (envelope→service→PG/Redis→adapters)
|
||||
4. Hygiene + conformance harness
|
||||
Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS.
|
||||
|
||||
## Dogfood evidence — live failure classes, not hypotheticals
|
||||
|
||||
> Newest first. Oldest entries roll to `BOARD-LEDGER.md` via `board-roll.sh` when this file
|
||||
> exceeds its 8 KB cap. Keystone detail is duplicated in `TASKS.md` §1a, so rolling loses nothing.
|
||||
|
||||
<!-- BOARD-ROLL:START -->
|
||||
|
||||
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
||||
|
||||
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
||||
|
||||
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
||||
|
||||
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
||||
|
||||
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
||||
|
||||
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
||||
|
||||
<!-- BOARD-ROLL:END -->
|
||||
|
||||
## Decisions log
|
||||
|
||||
- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active.
|
||||
- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python
|
||||
orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0
|
||||
milestone banner is cosmetic. (Supersedes any plan to repair it.)
|
||||
- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean.
|
||||
Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene.
|
||||
- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single
|
||||
named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,424 +0,0 @@
|
||||
# Adversarial Decomposition — Pragmatic / Shortest-Path Side
|
||||
|
||||
**Planner:** `planner-sol`
|
||||
**Bias:** make one real fleet task pass through one enforced path as early as possible; reuse before building.
|
||||
**Scope source:** `MISSION.md`, `MACP-WIRING-SCOUT.md`, `BOARD.md`, existing `@mosaicstack/macp`, `packages/coord`, PG/Valkey, Tess durable inbox/outbox, and the Mission Control PRD.
|
||||
|
||||
## Executive position
|
||||
|
||||
The first useful milestone is **not** “complete Builds 1 and 2.” It is this narrow vertical slice:
|
||||
|
||||
> A DB-backed mission task is atomically claimed by `packages/coord`, executed by one Node `@mosaicstack/macp` TaskExecutor, gated, and terminally recorded with identity-bound events and a tri-state mutation result. No `docs/TASKS.md`, `mission.json`, `tasks.json`, Python gate loop, or NDJSON ledger participates.
|
||||
|
||||
That slice is **SOL-03 → SOL-04 → SOL-05 → SOL-06 → SOL-07 → SOL-08**, estimated at **72K tokens**, mostly Codex. PG polling is acceptable for this first proof. Redis acceleration follows only after correctness is observable. This is the shortest path that is both dogfoodable and not throwaway work.
|
||||
|
||||
### Cost posture
|
||||
|
||||
- **25 PR tasks, ~294K tokens total:** ~164K Codex, ~130K Sonnet, **0K Opus**.
|
||||
- First live choke-point dogfood: ~72K on the hard path; SOL-01 and SOL-02 can run beside it.
|
||||
- Opus is not justified for planned implementation. Escalate only if an independent security review finds an unresolved architecture-level authority flaw.
|
||||
- Every row is one PR. Estimates include implementation, focused tests, docs affected by that PR, and one remediation pass—not orchestration/reviewer overhead.
|
||||
|
||||
## Gates and critical path
|
||||
|
||||
| gate | opens when | proof required before downstream work |
|
||||
| ------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| **G0 — trustworthy launch gates** | SOL-01, SOL-02 | non-root checkout works; queue status cannot become false-green |
|
||||
| **G1 — first dogfood / minimum viable cut** | SOL-08 | one live fleet task completes DB → MACP executor → gates → DB with no flat-file state |
|
||||
| **G2 — Builds 1+2 closed** | SOL-09..SOL-12 | all producers use the executor; duplicate islands retired; Redis loss is recoverable from PG |
|
||||
| **G3 — rotation real** | SOL-13..SOL-16 | stale generation cannot mutate; fresh session resumes typed state; Pi-brick recovery works without broker |
|
||||
| **G4 — sole-path comms real** | SOL-17..SOL-22 | roster identity is stable; bounced/stale messages converge through PG/Redis and adapters |
|
||||
| **G5 — mission proof** | SOL-23..SOL-25 | workflow sweep cannot capture unknown files; fault bank passes, including 100 rotations |
|
||||
|
||||
**Critical path:** `03 → 04 → 05 → 06 → 08 → 10 → 12 → 13 → 14 → 15 → 16 → 17 → 18 → 19 → 20 → 21 → 22 → 24 → 25`.
|
||||
|
||||
## Ordered task list
|
||||
|
||||
### SOL-01 — Repair activation coherence and non-root checkout hygiene
|
||||
|
||||
- **build:** 5 (hygiene; pulled forward)
|
||||
- **depends_on:** —
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. A clean non-root checkout can run dependency/bootstrap preparation without accessing `/root`.
|
||||
2. A root CI checkout still uses an isolated writable pnpm store.
|
||||
3. Activation installs CLI, hooks, broker/runtime assets, and version manifest transactionally: induced failure leaves the prior complete generation active.
|
||||
4. Launch with a deliberately skewed component version is rejected with the exact repair command; diagnostics remain usable.
|
||||
5. No test uses `--no-verify` or suppresses hooks.
|
||||
- **dogfood seed:** BOARD D-1 root-pinned `.npmrc` and D-2 root-owned Husky path.
|
||||
- **est. tokens:** 6K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-02 — Make queue guard fail-safe with exit-asserting tests
|
||||
|
||||
- **build:** 1
|
||||
- **depends_on:** —
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Fixture responses `pending`, `success`, `failure`, no-status, malformed JSON, provider error, and unknown status produce explicitly asserted process exits.
|
||||
2. Only terminal success/no-active-queue returns 0; unknown, malformed, and transport failure return non-zero with actionable output.
|
||||
3. A payload larger than 150 KiB is consumed without argv expansion or truncation.
|
||||
4. At least one mutant changes unknown→success and is killed by the test suite.
|
||||
- **dogfood seed:** inert gate-6 and recursive #1019 failure (unknown→exit 0; ARG_MAX).
|
||||
- **est. tokens:** 8K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-03 — Complete the canonical MACP contract, not another protocol
|
||||
|
||||
- **build:** 1
|
||||
- **depends_on:** —
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. `@mosaicstack/macp` validates typed Task, TaskResult, lifecycle Event, state Claim, and `verified | written-unverified | failed` mutation outcome records.
|
||||
2. Claims require source, confidence, issued-at, TTL/expiry, refresh instruction, and HMAC integrity; tamper/expiry returns a typed refusal, never partial data.
|
||||
3. Lifecycle events include launch, mission generation, checkpoint, rotation, recovery, inbox receipt, and terminal disposition while preserving existing task events.
|
||||
4. `MOSAIC_AGENT_NAME` is required for mutating execution and appears in credential/actor binding; missing identity fails closed.
|
||||
5. Target metadata requires repository identity, task/record ID, and head or generation where applicable.
|
||||
- **dogfood seed:** rev-974 identity drift plus the three observed write outcomes.
|
||||
- **est. tokens:** 8K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-04 — Add the narrow PG orchestration spine schema
|
||||
|
||||
- **build:** 2
|
||||
- **depends_on:** SOL-03
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Migration up creates mission, task, dependency, task-claim, MACP event, typed state-claim, session-generation, and dispatch-outbox records with tenant/mission keys and uniqueness constraints.
|
||||
2. The database rejects a task without a mission, a dependency outside its mission, duplicate idempotency keys, and terminal→running regression.
|
||||
3. Event and claim records reference canonical mission/task/generation identities; claims store integrity metadata.
|
||||
4. Migration rollback on an empty test DB succeeds; rerunning migration is safe.
|
||||
5. No comms-specific “universal message” schema is invented here; Build 4 reuses/extends existing interaction inbox/outbox tables.
|
||||
- **dogfood seed:** mission convention existed but a lane could act with no mechanically valid mission/task.
|
||||
- **est. tokens:** 12K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-05 — Implement atomic PG task claims, transitions, ledger, and outbox
|
||||
|
||||
- **build:** 2
|
||||
- **depends_on:** SOL-04
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Two concurrent claimers for one runnable task yield exactly one lease owner.
|
||||
2. Dependencies are evaluated transactionally; an unmet dependency can never be claimed.
|
||||
3. Claim, state transition, MACP event, and dispatch-outbox append commit atomically or all roll back.
|
||||
4. Expired leases are reclaimable with a higher fencing generation; stale owners cannot complete or mutate.
|
||||
5. Querying mission status is derived solely from PG and returns the next runnable task deterministically.
|
||||
- **dogfood seed:** model-maintained live board and stale claims surviving session changes.
|
||||
- **est. tokens:** 12K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-06 — Build the one production Node MACP TaskExecutor
|
||||
|
||||
- **build:** 1
|
||||
- **depends_on:** SOL-03, SOL-05
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. A public Node executor accepts only a claimed canonical MACP Task, resolves credentials/identity, runs the worker, runs structured gates, and persists terminal result/events through SOL-05.
|
||||
2. Worker exit 0 plus a failed gate cannot produce `completed`; worker failure cannot skip terminal ledger emission.
|
||||
3. Claude, Codex, and Pi fixture backends emit the same runtime-neutral lifecycle sequence.
|
||||
4. Every mutation returns one mandatory tri-state outcome; callers cannot compile while discarding it.
|
||||
5. Crash after worker success but before terminal commit leaves a recoverable fenced claim and no false completion.
|
||||
- **dogfood seed:** stranded MACP, gate-6 inert completion, and `written-unverified` being treated as success.
|
||||
- **est. tokens:** 16K
|
||||
- **suggested runtime tier:** sonnet
|
||||
|
||||
### SOL-07 — Provide one-shot flat-file import and cutover readiness audit
|
||||
|
||||
- **build:** 2
|
||||
- **depends_on:** SOL-05
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. A dry-run parses existing mission/TASKS artifacts, reports unsupported/ambiguous rows, and performs zero writes.
|
||||
2. Apply is idempotent and records source digests; repeated apply creates no duplicates.
|
||||
3. Unknown status, dangling dependency, duplicate task ID, and malformed table block import with row-level diagnostics.
|
||||
4. Readiness reports “cutover-ready” only when imported PG projections exactly match source counts/dependencies/statuses.
|
||||
5. This command is migration-only; it exposes no dual-write or ongoing sync mode.
|
||||
- **dogfood seed:** current remediation board/TASKS state needs a clean DB landing without silently losing tasks.
|
||||
- **est. tokens:** 8K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-08 — Hard-cut `packages/coord` to PG and dogfood one live task
|
||||
|
||||
- **build:** 1
|
||||
- **depends_on:** SOL-06, SOL-07
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. `mosaic coord run/status/continue` reads and mutates PG only; absent/unmigrated DB state fails with the SOL-07 repair path.
|
||||
2. No fallback reads/writes `docs/TASKS.md`, mission JSON, task JSON, state JSON, results JSON, or events NDJSON.
|
||||
3. A live canary task assigned to a fleet seat travels PG claim → TaskExecutor → worker → gate → terminal PG result/event and closes only after gate success.
|
||||
4. Killing the coordinator after claim and restarting it neither duplicates execution nor allows the stale lease to close the task.
|
||||
5. Evidence query shows actor seat, mission/task, target metadata, gate results, and tri-state outcome.
|
||||
- **dogfood seed:** this remediation mission itself; reproduce a gate-6-style non-null task and identity-bound write.
|
||||
- **est. tokens:** 16K
|
||||
- **suggested runtime tier:** sonnet
|
||||
|
||||
> **G1 FIRST-DOGFOOD:** stop and validate here before broadening. If SOL-08 cannot carry a real task, do not build Redis, rotation, comms, or UI.
|
||||
|
||||
### SOL-09 — Route Forge and OpenClaw/MACP producers through TaskExecutor
|
||||
|
||||
- **build:** 1
|
||||
- **depends_on:** SOL-08
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Forge and the OpenClaw MACP runtime submit the canonical Task type to SOL-06; neither executes a worker or gate itself.
|
||||
2. Their success callbacks are derived from canonical terminal results, not local/stub completion.
|
||||
3. A failed canonical gate is observed identically from Coord, Forge, and OpenClaw fixtures.
|
||||
4. Repository search plus an executable import boundary test finds no production-local redefinition of Task/TaskResult/GateResult on these paths.
|
||||
- **dogfood seed:** Forge’s immediate empty-gate completion and the plugin’s redefined MACP-shaped result.
|
||||
- **est. tokens:** 10K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-10 — Retire flat-file orchestration and the disabled duplicate rail
|
||||
|
||||
- **build:** 1
|
||||
- **depends_on:** SOL-09
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. The Python controller execution/gate/event path, `tasks_md_sync`, plugin-local protocol types, orphaned context loader, and production flat-file orchestration writers/readers are absent from shipped assets.
|
||||
2. Framework guides/templates/startup context point to DB mission commands, not `docs/TASKS.md` as orchestration SoR.
|
||||
3. A regression scan fails CI if production code reintroduces `events.ndjson`, `tasks.json`, `mission.json`, or `docs/TASKS.md` orchestration mutation.
|
||||
4. jarvis-brain PDA flat files and unrelated project docs remain untouched.
|
||||
5. Upgrade removes/quarantines obsolete generated rail files without deleting user source/docs.
|
||||
- **dogfood seed:** three parallel islands and stale `.mosaic/orchestrator/mission.json` 0/0 residue.
|
||||
- **est. tokens:** 14K
|
||||
- **suggested runtime tier:** sonnet
|
||||
|
||||
### SOL-11 — Add Redis hot dispatch as a derived outbox consumer
|
||||
|
||||
- **build:** 2
|
||||
- **depends_on:** SOL-08
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Task creation commits mission/task/outbox in PG before any Redis enqueue.
|
||||
2. Induced Redis failure leaves the task durable and pending; a sweeper later enqueues it exactly once logically.
|
||||
3. Deleting the Redis queue and rebuilding from PG restores all non-terminal dispatches without reviving terminal tasks.
|
||||
4. Duplicate delivery is neutralized by PG claim fencing/idempotency.
|
||||
5. Existing `packages/queue` adapter/config is reused; no second broker API is introduced.
|
||||
- **dogfood seed:** inert/unknown queue transport and broker outage during task dispatch.
|
||||
- **est. tokens:** 12K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-12 — Lock Builds 1+2 with black-box failure cases
|
||||
|
||||
- **build:** 2
|
||||
- **depends_on:** SOL-02, SOL-10, SOL-11
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. A black-box suite proves: unknown queue status blocks; malformed task blocks; gate failure blocks completion; dropped identity blocks mutation; HMAC corruption forces refresh; Redis loss recovers from PG; stale lease cannot close.
|
||||
2. The suite invokes shipped CLI/service boundaries, not internal mocks.
|
||||
3. Every asserted failure checks process/result status and durable terminal/non-terminal state.
|
||||
4. The same canary task succeeds under Claude, Codex, and Pi adapters or a documented unavailable-runtime fixture fails explicitly.
|
||||
- **dogfood seed:** gate-6/#1019, identity drift, and built-but-unwired MACP.
|
||||
- **est. tokens:** 8K
|
||||
- **suggested runtime tier:** sonnet
|
||||
|
||||
### SOL-13 — Bind session authority to contract hash and generation
|
||||
|
||||
- **build:** 3
|
||||
- **depends_on:** SOL-12
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Launch computes a stable hash over the effective Constitution/AGENTS/runtime/skills set and stores it with session generation.
|
||||
2. Policy change or compaction detection marks the generation stale before any subsequent mutation.
|
||||
3. A stale/mismatched generation can read diagnostics but cannot claim, write task state, acknowledge comms, merge, or close.
|
||||
4. Re-attestation creates a new generation; old credentials/leases remain fenced.
|
||||
5. Hash input order/path normalization is deterministic across two clean launches.
|
||||
- **dogfood seed:** compacted orchestrator losing directives and D-4 ignoring an in-message reset.
|
||||
- **est. tokens:** 12K
|
||||
- **suggested runtime tier:** sonnet
|
||||
|
||||
### SOL-14 — Persist compact typed rotation checkpoints using Coord primitives
|
||||
|
||||
- **build:** 3
|
||||
- **depends_on:** SOL-13
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Checkpoint contains mission/task, completed/blocked state, next three actions, constraints, claims, contract hash/generation, and cursors—never transcript text.
|
||||
2. Checkpoint is HMAC-verified before rehydration; corrupt/expired/missing required claims refuse resume and request deterministic refresh.
|
||||
3. Writing checkpoint and rotation-intent event is atomic in PG.
|
||||
4. Existing Coord continuation capsule semantics are reused; no competing handoff schema/file is created.
|
||||
- **dogfood seed:** manual MOS-ORCHESTRATION-BOARD checkpoint and incomplete-rehydration risk.
|
||||
- **est. tokens:** 12K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-15 — Finish the deterministic coordinator rotation daemon
|
||||
|
||||
- **build:** 3
|
||||
- **depends_on:** SOL-14
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Configured token threshold triggers checkpoint → revoke old authority → terminate → launch fresh → verify rehydration in that order.
|
||||
2. Compaction-detected is a backstop that forces the same rotation path; it never requests recursive compaction.
|
||||
3. A launch failure leaves the mission recoverable and visibly paused, not assigned to two active generations.
|
||||
4. Ephemeral seats die/respawn without mission checkpoint; persistent/orchestrator seats rotate.
|
||||
5. The implementation extends `packages/coord`; untracked `apps/coordinator` residue is not revived.
|
||||
- **dogfood seed:** planner-sol dirty-context dispatch and the old coordinator’s log-only `_check_context()` behavior.
|
||||
- **est. tokens:** 16K
|
||||
- **suggested runtime tier:** sonnet
|
||||
|
||||
### SOL-16 — Add broker-independent recovery and remove silent MOSAIC BYPASS
|
||||
|
||||
- **build:** 3
|
||||
- **depends_on:** SOL-15
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. With Redis/broker unavailable, a diagnostic/bootstrap command can inspect PG mission state, repair broker configuration, and resume without traversing the broker gate.
|
||||
2. Normal recovery remains broker-gated and is labeled as such.
|
||||
3. Break-glass requires explicit scope and expiry, emits a durable event, displays a loud banner, and auto-expires; permanent/silent bypass text or behavior is absent.
|
||||
4. The Pi-brick fixture recovers the broker, then returns to normal gated operation without editing source/config by hand.
|
||||
5. Orchestrator guidance removes “/compact and continue” only after the rotation command is available; ephemeral guidance remains explicit.
|
||||
- **dogfood seed:** Pi brick and silent `MOSAIC BYPASS 2026-07-22`.
|
||||
- **est. tokens:** 12K
|
||||
- **suggested runtime tier:** sonnet
|
||||
|
||||
### SOL-17 — Converge each host on one roster-owned lifecycle domain
|
||||
|
||||
- **build:** 5 (hygiene; hard prerequisite for addressed comms)
|
||||
- **depends_on:** SOL-16
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Reconcile establishes exactly one roster-declared tmux socket/lifecycle domain per host.
|
||||
2. Unknown sessions are reported and quarantined; they are never killed without positive unmanaged classification.
|
||||
3. Max-age/max-context stale sessions invoke SOL-15 rotation for persistent seats or reap for ephemerals.
|
||||
4. Seat identity survives respawn and equals the roster/MOSAIC_AGENT_NAME binding.
|
||||
5. A fixture matching the current four unmanaged remediation seats converges them or produces explicit quarantine actions.
|
||||
- **dogfood seed:** scout-bounce and BOARD D-3 seats split between default and `mosaic-fleet` sockets.
|
||||
- **est. tokens:** 14K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-18 — Publish authenticated `comms/v1` envelope and compatibility rules
|
||||
|
||||
- **build:** 4
|
||||
- **depends_on:** SOL-13, SOL-17
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Envelope validates protocol version, message/idempotency ID, sender/recipient seat identity, class, ordering/coalesce key, creation/expiry, correlation, payload digest, and authentication.
|
||||
2. Current and immediately previous supported protocol versions are accepted; unsupported versions are rejected loudly with supported range.
|
||||
3. Framework/runtime version is diagnostic metadata and never the compatibility key.
|
||||
4. Forged sender, changed recipient/payload, expired envelope, and replay with conflicting content fail closed.
|
||||
- **dogfood seed:** wrong-socket bare tmux message with no authoritative sender/recipient receipt.
|
||||
- **est. tokens:** 8K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-19 — Build the logical PG-first comms service with tmux adapter
|
||||
|
||||
- **build:** 4
|
||||
- **depends_on:** SOL-18
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Sending commits envelope/payload and PENDING state in PG before adapter delivery.
|
||||
2. State machine enforces PENDING → RECEIVED → CONSUMED or DEAD-LETTER; illegal regressions are rejected.
|
||||
3. Recipient-filtered claims and append/coalesce policy are deterministic by message class.
|
||||
4. tmux is a dumb adapter: delivery failure changes no PG authority state and is retryable.
|
||||
5. Existing Tess durable repository/state-machine patterns are extended or generalized; no new deployable microservice or second inbox framework appears.
|
||||
- **dogfood seed:** MACP scout bounce that was discovered only by manual liveness check.
|
||||
- **est. tokens:** 16K
|
||||
- **suggested runtime tier:** sonnet
|
||||
|
||||
### SOL-20 — Make `agent-send` use the sole path and prove stale-message handling
|
||||
|
||||
- **build:** 4
|
||||
- **depends_on:** SOL-19
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Normal `agent-send` creates a comms/v1 record and observes RECEIVED/CONSUMED; it cannot directly invoke tmux.
|
||||
2. A wrong/missing socket leaves PENDING with retry diagnostics, then reaches RECEIVED after roster repair without resending.
|
||||
3. A stale coalescible message arriving after a newer terminal message is marked superseded/consumed and is not surfaced as live work.
|
||||
4. Duplicate identical send is idempotent; same ID with changed content is rejected.
|
||||
5. Inbox receipt/terminal disposition emits canonical MACP lifecycle events.
|
||||
- **dogfood seed:** scout-bounce and #1018 stale-consumed message arriving after merge.
|
||||
- **est. tokens:** 12K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-21 — Add Redis Streams hot delivery and PG reconciliation
|
||||
|
||||
- **build:** 4
|
||||
- **depends_on:** SOL-11, SOL-20
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. PG commit precedes XADD; induced XADD failure is repaired by sweeper.
|
||||
2. Consumer uses a PEL; ack sequence is PG CONSUMED commit before XACK.
|
||||
3. Redis flush/restart rebuilds pending delivery from PG without duplicating consumed messages.
|
||||
4. Pending, abandoned, and dead-letter transitions are observable with bounded retry/backoff.
|
||||
5. Existing Redis/queue connection/configuration is reused.
|
||||
- **dogfood seed:** delivery bounce plus broker loss between durable write and hot enqueue.
|
||||
- **est. tokens:** 12K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-22 — Prove adapter pluggability with the existing Matrix connector
|
||||
|
||||
- **build:** 4
|
||||
- **depends_on:** SOL-21
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Existing Matrix connector consumes/produces comms/v1 through SOL-19 without owning authority state.
|
||||
2. The same envelope can fail tmux and later deliver through Matrix while producing one logical message lifecycle.
|
||||
3. Matrix retry/reconnect cannot regress PG state or duplicate CONSUMED work.
|
||||
4. Removing Matrix availability leaves PG/Redis/tmux behavior intact.
|
||||
- **dogfood seed:** cross-socket scout notification bounce; alternate reach must not become alternate authority.
|
||||
- **est. tokens:** 8K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-23 — Constrain auto-sync and agent writes by allowlist and lease
|
||||
|
||||
- **build:** 5
|
||||
- **depends_on:** SOL-10
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Auto-sync stages only an explicit allowlist; an unknown modified/untracked docs/source file remains unstaged and is reported.
|
||||
2. Agent source/docs writes require the correct worktree/lease; two seats cannot acquire the same mutable target concurrently.
|
||||
3. Generated files are positively identified, not inferred by denylist.
|
||||
4. The measured annotation/index mid-write fixture cannot be swept into an unrelated commit.
|
||||
5. DB orchestration state is absent from repository staging concerns.
|
||||
- **dogfood seed:** auto-sync sweep commit `517bd5c26` capturing agent-authored docs mid-write.
|
||||
- **est. tokens:** 8K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
### SOL-24 — Build the real-artifact lifecycle conformance harness
|
||||
|
||||
- **build:** 5
|
||||
- **depends_on:** SOL-16, SOL-17, SOL-22, SOL-23
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Harness launches shipped CLI/runtime artifacts and fault-injects compaction, broker outage, delivery bounce, identity drop, stale contract hash, queue unknown/malformed, Redis loss, and auto-sync collision.
|
||||
2. One deterministic test executes 100 sequential rotations with no lost/duplicated task, claim, receipt, or terminal disposition.
|
||||
3. Tests assert DB state/event order and process exits, not log substrings alone.
|
||||
4. Harness runs against isolated PG/Redis namespaces and cleans only resources it created.
|
||||
5. Every banked dogfood seed has a named case and evidence output suitable for CI/release attachment.
|
||||
- **dogfood seed:** the complete failure bank: Pi brick, scout-bounce, gate-6/#1019, identity drift, auto-sync, #1018 stale-consumed, D-4 dirty context.
|
||||
- **est. tokens:** 18K
|
||||
- **suggested runtime tier:** sonnet
|
||||
|
||||
### SOL-25 — Complete operator cutover docs and activation proof
|
||||
|
||||
- **build:** 5
|
||||
- **depends_on:** SOL-01, SOL-02, SOL-10, SOL-16, SOL-22, SOL-24
|
||||
- **acceptance criteria (diff-blind testable):**
|
||||
1. Operator docs give exact DB import/cutover, rollback-before-cutover, recovery, break-glass expiry, rotation, comms, quarantine, and conformance commands.
|
||||
2. Link/command checks find no orchestrator instruction to mutate flat-file mission/tasks, use silent bypass, direct-tmux normal comms, or “compact and continue” a persistent seat.
|
||||
3. A clean non-root install activates one coherent version and runs the conformance smoke subset.
|
||||
4. Release evidence maps all 15 decisions and every live seed to a passing check or an explicit deferred item below.
|
||||
- **dogfood seed:** activation skew plus the tendency to leave built fixes unwired or undocumented.
|
||||
- **est. tokens:** 6K
|
||||
- **suggested runtime tier:** codex
|
||||
|
||||
## Explicit DEFER list (10)
|
||||
|
||||
These are not rejected; they are **past first dogfood** and should not delay G1/G2. Each is gold-plating unless a live failure makes it necessary.
|
||||
|
||||
1. **DEFER — Mission dashboard/TUI views.** CLI/DB queries are enough to operate and prove the spine.
|
||||
2. **DEFER — PRD-to-board automatic decomposition.** This is LLM/judgment-heavy and unrelated to enforcing already-decided tasks.
|
||||
3. **DEFER — General heuristic churn scoring.** Implement token threshold + compaction sensor first; repeated-tool-loop inference can follow measured need.
|
||||
4. **DEFER — Discord comms adapter.** Existing plugin reach remains; migrate only after tmux+Matrix prove the service contract.
|
||||
5. **DEFER — Slack comms adapter.** No current dogfood dependency.
|
||||
6. **DEFER — Telegram comms adapter.** No current dogfood dependency.
|
||||
7. **DEFER — Public MCP comms surface.** `agent-send` and service API are sufficient for the mission proof.
|
||||
8. **DEFER — Protocol-v2 features/general negotiation framework.** Ship v1 with a bounded current/previous acceptance window; do not predict v2.
|
||||
9. **DEFER — Multi-region/HA PG or Redis.** Existing in-stack PG+Redis and rebuildability satisfy current failure classes.
|
||||
10. **DEFER — Event analytics/search UI and long-term warehouse.** Indexed PG evidence plus CLI queries is enough for audit/conformance.
|
||||
|
||||
## Suspect abstractions register
|
||||
|
||||
| proposed thing | verdict |
|
||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Canonical Node `TaskExecutor` | **JUSTIFIED:** explicitly required single choke point; wraps existing MACP functions rather than replacing them. |
|
||||
| PG repository methods | **JUSTIFIED but narrow:** ordinary adapters around existing Drizzle/DB patterns, not a new “state platform.” |
|
||||
| Rotation daemon | **JUSTIFIED:** finishes `packages/coord`; do not revive `apps/coordinator` or create another service. |
|
||||
| Comms service | **JUSTIFIED only as a logical in-process boundary:** reuse Tess durable inbox/outbox and existing connectors; no new deployable microservice this cycle. |
|
||||
| Universal queue/broker abstraction | **SUSPECT / DO NOT BUILD:** reuse `packages/queue`, PG outbox, and Redis Streams/BullMQ configuration already present. |
|
||||
| Universal envelope/state framework | **SUSPECT / DO NOT BUILD:** MACP Task/Claim/Event and comms/v1 have different bounded purposes. |
|
||||
| Generic compatibility-negotiation engine | **SUSPECT / DEFER:** a small supported-version check meets v1 needs. |
|
||||
|
||||
## Dissent (7)
|
||||
|
||||
1. **Do not wire new production behavior into `mosaic_orchestrator.py::run_single_task`.** The scout correctly identified the duplicated block, but BOARD’s later ruling says the disabled Python rail is residue and must be retired. Building a Node bridge only to delete it is throwaway. Put the Node TaskExecutor in `@mosaicstack/macp`, route the live Coord path to it at SOL-08, migrate remaining producers at SOL-09, then delete the Python block at SOL-10.
|
||||
2. **Redis is not on the first-dogfood critical path.** PG claim/polling is sufficient for one real task and exposes correctness earlier. Add Redis only after G1; otherwise queue debugging obscures whether the choke point works.
|
||||
3. **“One choke-point service” does not justify a new deployable service.** An exported executor plus Coord daemon is enough. A new Nest app, RPC protocol, deployment, auth layer, and health plane would be greenfield.
|
||||
4. **The Mission Control PRD’s file-first and board-regeneration assumptions are superseded.** Keep its mission/rotation semantics, but obey the accepted hard DB cutover; do not implement its file-first milestones or PRD-to-board generator now.
|
||||
5. **Do not gate every interactive runtime launch as if it were a mission task.** Enforce every tracked task/data mutation at the executor/DB authority boundary. Ephemeral interactive shells may launch, but receive no task mutation authority unless attached to a valid claim.
|
||||
6. **Do not implement broad “churn intelligence.”** Token threshold and compaction-detected are deterministic sensors. Repeated-loop semantic detection is expensive, noisy, and premature until telemetry demonstrates a gap.
|
||||
7. **The 100-rotation test is a final conformance bar, not an early unit-test tax.** First prove one rotation, then fault cases, then 100 repetitions in SOL-24. Requiring 100 before G3 would delay feedback without changing the design.
|
||||
|
||||
## Orchestrator reconciliation notes
|
||||
|
||||
- Pre-register each task’s acceptance checks from this document before showing implementation diffs to its reviewer. Author and reviewer remain different seats.
|
||||
- SOL-07 permits a one-time import, **not** an interim store: no shadow writes, dual reads, or sync daemon.
|
||||
- G1 is the budget escape hatch. If the 72K hard-path slice does not work, stop and remediate instead of spending the remaining ~222K.
|
||||
- Docs belong in each behavior-changing PR where required; SOL-25 is cross-link/cutover validation, not permission to postpone essential docs.
|
||||
@@ -1,49 +0,0 @@
|
||||
# mos-remediation — Orchestrator Kickstart / Compaction-Survival Resume
|
||||
|
||||
**You are `mos-remediation`, the project orchestrator for the Mosaic Stack remediation, launched in `/src/mosaic-stack`.**
|
||||
This file is your fail-closed resume procedure. Read it on EVERY fresh/cleared session and on the FIRST turn
|
||||
after any compaction. This mission's whole point is that manual compaction-survival is fragile — so follow this
|
||||
mechanically until Build 3 (rotation) makes it automatic.
|
||||
|
||||
## On resume (do in order, before any orchestration action)
|
||||
|
||||
1. `cd /src/mosaic-stack`, then **`git fetch origin remediation/state`**.
|
||||
⚠ **The live board is on the rolling branch `remediation/state`, NOT on `main`.** `main` carries only
|
||||
periodic snapshots, so reading the board from `main` will silently give you a STALE tick. Read the
|
||||
live files at `origin/remediation/state` (e.g. `git show origin/remediation/state:docs/remediation/BOARD.md`),
|
||||
or check that branch out. Every tick is pushed there immediately, so its HEAD is always the newest state.
|
||||
2. Read `docs/remediation/MISSION.md` — the charter (goal, 4 builds, 15 decisions, sequencing, directives).
|
||||
3. Read `docs/remediation/BOARD.md` **at `origin/remediation/state`** — the LIVE state: current phase,
|
||||
in-flight tasks, fleet seat assignments, gate status. Single source of in-flight truth (kept < 8 KB;
|
||||
older entries roll to `BOARD-LEDGER.md` via `board-roll.sh`).
|
||||
4. Read the discussion checkpoint for full rationale if needed:
|
||||
`../jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md` (or the jarvis-brain repo path).
|
||||
5. **Residency attestation (fail-closed):** restate from the reloaded files — (a) the goal in one line, (b) the
|
||||
current build/phase, (c) the BOARD head (in-flight tasks + who owns them). If you cannot, HALT and re-read.
|
||||
Do NOT act on memory alone; a compaction may have dropped context silently.
|
||||
|
||||
## Standing invariants (never violate)
|
||||
|
||||
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
|
||||
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
|
||||
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
|
||||
- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them).
|
||||
- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits.
|
||||
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
|
||||
|
||||
## After every significant event
|
||||
|
||||
Overwrite stale lines in `BOARD.md`, keep it < 8 KB, commit + push. The board IS your checkpoint until the
|
||||
DB-backed rotation daemon (Build 3) exists. Persist typed state (phase, tasks, owners, gates) — never the transcript.
|
||||
|
||||
## Fleet
|
||||
|
||||
- Adversarial planners: `planner-opus` (robustness), `planner-sol` (pragmatic) — dispatch for task decomposition; reconcile their oppositional decomps.
|
||||
- Coders/reviewers: dispatch per roster + delivery gates. Comms: `~/.config/mosaic/tools/tmux/agent-send.sh`
|
||||
(`-L <socket> -s <dst> -S <yourhost>:<yourseat> --class <class>`); always pass `-S`.
|
||||
- Lead coordinator: Mos (`mos-claude`). Escalate only on the Constitution's escalation triggers.
|
||||
|
||||
## Remote control
|
||||
|
||||
On first startup, activate remote control for this session (`/remote-control`) so Jason can reach/drive you while
|
||||
away. If the command is unavailable in this runtime, report it to Mos and continue — it is not a blocker.
|
||||
@@ -1,98 +0,0 @@
|
||||
# MACP wiring investigation
|
||||
|
||||
**Scope:** `/src/mosaic-stack` inspected at HEAD `b79336a8c11e2a4646a47ff8d295a226e0c71404`; read-only. Existing dirty/untracked state was not touched.
|
||||
|
||||
## Verdict
|
||||
|
||||
**(c) STRANDED.** `packages/macp` is exported, unit-tested, and registered as a CLI command group, but no production dispatch/execution code invokes its credential resolver, gate runner, or event emitter.
|
||||
A separate MACP-named OpenClaw/orchestrator rail exists, but it redefines task/result types and gate/event logic instead of importing `@mosaicstack/macp`; direct `mosaic yolo|claude|codex|opencode|pi` also bypasses it.
|
||||
|
||||
## 1. Production call sites vs tests
|
||||
|
||||
### Production references to `@mosaicstack/macp`
|
||||
|
||||
| Surface | Evidence | Actual use |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Unified CLI | `packages/mosaic/src/cli.ts:8,385` | Imports and registers `registerMacpCommand`; no task/gate/event execution. |
|
||||
| Forge | `packages/forge/src/types.ts:1,17,68,79` | **Type-only** imports of `GateEntry` and `TaskResult`. Pipeline calls an injected abstract executor at `packages/forge/src/pipeline-runner.ts:189-190,299-300`, not MACP. |
|
||||
| Mosaic package metadata | `packages/mosaic/package.json:36`; `packages/mosaic/src/runtime/update-checker.ts:172` | Dependency/update inventory only. |
|
||||
| Agent | No match under production `packages/agent/src/**` | No MACP import/call. |
|
||||
| Coord | No match under production `packages/coord/src/**`; dependency list is only `@mosaicstack/types` at `packages/coord/package.json:25-27` | No MACP import/call. |
|
||||
| Plugins | No `@mosaicstack/macp` import under `plugins/**` | No package use; the MACP-named plugin is an independent implementation (below). |
|
||||
|
||||
**Repository-wide production call-site search result:** excluding `packages/macp/**`, tests, worktrees, and build output, there are **zero** calls to `runGate`, `runGates`, `emitEvent`, `appendEvent`, or `resolveCredentials`.
|
||||
|
||||
### `packages/macp` implementation is internally connected only
|
||||
|
||||
- Public exports: `packages/macp/src/index.ts:1-48` exports Task/GateEntry/MACPEvent/TaskResult, credential resolution, `runGate(s)`, risk-floor, and event emission.
|
||||
- Gate runner calls its own event emitter: `packages/macp/src/gate-runner.ts:187-236`.
|
||||
- Event persistence implementation appends NDJSON to a caller-supplied path: `packages/macp/src/event-emitter.ts:11-27`.
|
||||
- There is **no exported programmatic `submit` implementation** in `packages/macp/src/index.ts:1-48`; only the CLI placeholder named `submit`.
|
||||
|
||||
### Test-only invocations
|
||||
|
||||
- Gate runner: `packages/macp/__tests__/gate-runner.test.ts:96-242` invokes `runGate/runGates`.
|
||||
- Event ledger: `packages/macp/__tests__/event-emitter.test.ts:46-133` invokes `appendEvent/emitEvent` against temporary `events.ndjson` files.
|
||||
- Credential resolver: `packages/macp/__tests__/credential-resolver.test.ts` exercises resolver behavior.
|
||||
- CLI tests only verify command registration: `packages/macp/src/cli.spec.ts:37-73`; `packages/mosaic/src/cli-smoke.spec.ts:8` imports registration.
|
||||
|
||||
## 2. Gate on the live dispatch path
|
||||
|
||||
### Direct Mosaic runtime launch bypasses MACP
|
||||
|
||||
- Runtime commands dispatch directly to harness launch: `packages/mosaic/src/commands/launch.ts:730-801`.
|
||||
- Claude/Pi go through the lease broker, then spawn the runtime: `packages/mosaic/src/commands/launch.ts:817-843`.
|
||||
- Commander wiring sends `mosaic yolo <runtime>` and direct runtime commands to `launchRuntime`: `packages/mosaic/src/commands/launch.ts:1102-1157,1165-1167`.
|
||||
- None of those ranges imports/calls `@mosaicstack/macp`, `runGates`, or `emitEvent`.
|
||||
|
||||
**Result:** a direct `mosaic yolo`, `mosaic claude/codex/opencode/pi`, or underlying exec does not create a typed MACP Task, run the package gate-runner, or append a package MACPEvent.
|
||||
|
||||
### Coord bypasses MACP
|
||||
|
||||
- Coord reads/updates `docs/TASKS.md`: `packages/coord/src/runner.ts:6,306-386`; parser/writer is `packages/coord/src/tasks-file.ts:326-377`.
|
||||
- Coord launches a child process directly: `packages/coord/src/runner.ts:397-427`.
|
||||
- Mission state is its own `.mosaic/orchestrator/mission.json`/`next-task.json`: `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:15-16,355-384`.
|
||||
|
||||
**Result:** Coord task execution has no MACP Task validation, package gate runner, or event append.
|
||||
|
||||
### Forge bypasses MACP execution
|
||||
|
||||
- Forge defines its own `ForgeTask` and abstract `TaskExecutor`: `packages/forge/src/types.ts:48-80`.
|
||||
- The production CLI injects a **stub executor** that immediately reports completion with empty gates: `packages/forge/src/cli.ts:13-31,167,185`.
|
||||
|
||||
**Result:** even `mosaic forge run` does not execute MACP gates or persist MACP events.
|
||||
|
||||
### Separate MACP-named rail is not `packages/macp`
|
||||
|
||||
- OpenClaw plugin registers an ACP backend named `macp`: `plugins/macp/src/index.ts:1-18,72-102`.
|
||||
- It locally redefines `OrchestratorTask`, `TaskResult`, and gate-result shapes instead of importing package types: `plugins/macp/src/macp-runtime.ts:43-77`.
|
||||
- It appends directly to `.mosaic/orchestrator/tasks.json`, triggers an external controller, and polls `results/<task>.json`: `plugins/macp/src/macp-runtime.ts:290-329,437-483`.
|
||||
- The controller independently implements `append_event`, `emit_event`, shell execution, gate execution, and results: `packages/mosaic/framework/tools/orchestrator-matrix/controller/mosaic_orchestrator.py:29-91,126-276`.
|
||||
- Its gate loop runs raw string gates after worker success: `mosaic_orchestrator.py:213-235`; it does not support the package's structured `GateEntry`/AI-review behavior.
|
||||
- Current checkout disables this controller: `.mosaic/orchestrator/config.json:2` (`"enabled": false`).
|
||||
- Plugin references `tools/macp/dispatcher/pi_runner.ts` at `plugins/macp/src/macp-runtime.ts:85-91`, but `tools/macp/` does not exist in this checkout.
|
||||
|
||||
**Result:** there is a parallel, optionally enabled MACP-shaped rail, not package integration. It cannot make `packages/macp` the enforced path.
|
||||
|
||||
## 3. Event ledger status
|
||||
|
||||
- Package persistence exists only as a library primitive: `packages/macp/src/event-emitter.ts:11-27` appends JSON lines to an arbitrary `eventsPath`.
|
||||
- Package event emission is reached only from package `runGates`: `packages/macp/src/gate-runner.ts:204-236`.
|
||||
- No production caller invokes package `runGates/emitEvent/appendEvent`; therefore no runtime destination path is configured for the package ledger.
|
||||
- Test-only ledgers use temp paths: `packages/macp/__tests__/event-emitter.test.ts:35-133`; gate tests use temp `events.ndjson`: `packages/macp/__tests__/gate-runner.test.ts:171-242`.
|
||||
- The separate Python controller writes `.mosaic/orchestrator/events.ndjson`: `mosaic_orchestrator.py:129-133,159-161,219-235`; the Mosaic Framework plugin only **reads** that file for context at `plugins/mosaic-framework/src/index.ts:279-316,430-438`.
|
||||
- In this checkout, `.mosaic/orchestrator/events.ndjson` is absent and the controller is disabled (`.mosaic/orchestrator/config.json:2`).
|
||||
|
||||
**Conclusion:** `MACPEvent` from `packages/macp` is defined/tested but not emitted or persisted by live production call sites. The similarly shaped Python ledger is a duplicate island.
|
||||
|
||||
## 4. Coord link
|
||||
|
||||
- `packages/coord` has no `@mosaicstack/macp` dependency/import: `packages/coord/package.json:25-27`; no matches in `packages/coord/src/**`.
|
||||
- Coord's task model is Markdown `docs/TASKS.md` plus mission/session JSON: `packages/coord/src/tasks-file.ts:1-10,257-377`; `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:306-427`.
|
||||
- It does not consume `.mosaic/orchestrator/events.ndjson`, MACP Task, MACPEvent, GateEntry, or TaskResult.
|
||||
|
||||
**Conclusion:** Coord and `packages/macp` are disconnected islands.
|
||||
|
||||
## Shortest wiring gap
|
||||
|
||||
**Single integration point:** replace the duplicated execution/gate/event block in `mosaic_orchestrator.py::run_single_task` (`:126-276`) with one production Node `TaskExecutor` backed by `@mosaicstack/macp` (typed Task validation + `resolveCredentials` + `runGates` + `emitEvent`), and make Coord/Forge/OpenClaw submit through that executor. This queue/controller choke point is where `yolo|acp|exec` worker outcomes can be gated and journaled before completion is recorded.
|
||||
@@ -1,166 +0,0 @@
|
||||
# Mosaic Stack Remediation — Mission Charter
|
||||
|
||||
**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`).
|
||||
**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** EXECUTING (planning complete; RM-01 in flight).
|
||||
**HOLD lifted** for this workstream by Jason, 2026-07-31 — "begin full mosaic fleet operation on this."
|
||||
|
||||
## Goal
|
||||
|
||||
Convert the 15 accepted postmortem remediation proposals into a working, **dogfooded** implementation.
|
||||
**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic
|
||||
gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### First-class principle — observe the property, not the exit code
|
||||
|
||||
> **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.**
|
||||
>
|
||||
> **Success output is designed to be believed.** That is the whole reason the inert-gate class exists
|
||||
> and why P-WRAPPER-001's tri-state (`verified` / `written-unverified` / `failed`) is not optional. The
|
||||
> failure is not carelessness — a green is _engineered_ to be trusted, so trusting it is the default
|
||||
> behaviour of a competent operator, not a lapse.
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-07-31) after the orchestrator committed this exact error: a
|
||||
> `--draft` flag was silently dropped by a wrapper fallback that still exited 0, and the PR was reported
|
||||
> as a draft on the strength of the exit code rather than an observed `draft: true` (D-12). Twelve
|
||||
> failure instances were banked in that session; **three of them were the orchestrator's own.** That
|
||||
> ratio is the point — the mechanism must catch the mechanic too, or it is not a mechanism.
|
||||
>
|
||||
> Operationally: after any write, read back the property you required. Applies to gates, wrappers, PR
|
||||
> flags, commit authorship, file installs, and message delivery alike.
|
||||
|
||||
### First-class principle — pre-registration prevents retrofitting, and nothing else
|
||||
|
||||
> **A pre-registered check set can fail in three distinct ways:**
|
||||
>
|
||||
> | mode | the set is… | found as |
|
||||
> | --------------------------- | ------------------------------------------------- | -------- |
|
||||
> | **WRONG** | a check does not test what it claims | D-8 |
|
||||
> | **INCOMPLETE** | green while a criterion's requirement is untested | D-17 |
|
||||
> | **INTERNALLY INCONSISTENT** | two criteria cannot both hold | D-18 |
|
||||
>
|
||||
> **Pre-registration protects against exactly one thing: retrofitting a check to fit the implementation
|
||||
> it is supposed to judge.** It confers neither correctness, nor coverage, nor consistency. "We
|
||||
> pre-registered the checks" has been treated as though it settled the question — it settles one of
|
||||
> three.
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-07-31). All three modes were found on this mission's own **first
|
||||
> delivery**, by the machinery applied to its own work — not by inspection, and not by looking for them.
|
||||
>
|
||||
> **Enforceable form — RM-02's four clauses.** The registry must establish that: (1) each check is
|
||||
> **right** — proven red for its own stated reason before its green counts; (2) the set **covers** —
|
||||
> every criterion bound to a case that actually exercises it; (3) no two criteria **conflict** —
|
||||
> mutual unsatisfiability is a registry defect discoverable by construction; (4) when a criterion's
|
||||
> meaning changes, the registry **retains original text, restatement, and reason**, so evolution stays
|
||||
> auditable. A criterion with no case that can fail for its own reason is unregistered in substance,
|
||||
> however it reads in the manifest.
|
||||
|
||||
### Corollary — never ship an integrity claim dressed as a property
|
||||
|
||||
> A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a
|
||||
> manifest, marker, ledger, or receipt is writable by the same actor whose behaviour it certifies, it
|
||||
> **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to,
|
||||
> publish atomically, and carry a **tamper negative-control observed red** — otherwise its integrity is
|
||||
> a _claim_, not a _property_.
|
||||
>
|
||||
> **If it cannot be made tamper-evident, say so and reconsider the approach.** Laundering foreign
|
||||
> content as certified is the only unacceptable outcome; an honest "this cannot be verified" is always
|
||||
> available and always preferable.
|
||||
|
||||
### First-class principle — when a property cannot exist at the layer it was specified
|
||||
|
||||
> Some required properties are **impossible at the layer that asked for them** — not hard, impossible.
|
||||
> A local check cannot defend against an actor who can rewrite the check itself. When that happens,
|
||||
> there are exactly three honest moves, and all three are mandatory:
|
||||
>
|
||||
> 1. **Implement what the layer _can_ guarantee.** Partial protection against the class it was actually
|
||||
> born from is worth having.
|
||||
> 2. **State the boundary precisely, in BOTH directions.** What it does _not_ defend, **and** beside it
|
||||
> what it _does_. A reader who sees only the negative dismisses the check as worthless; one who sees
|
||||
> only the positive over-trusts it. **Both together is the honest artifact** — either alone misleads.
|
||||
> 3. **Record where the real guarantee will come from — as a TRACKED DEPENDENCY, not prose.** It must
|
||||
> name a task that someone must close. _A documented gap with no owner becomes a permanent gap that
|
||||
> reads as intentional._
|
||||
>
|
||||
> **A written-down gap is acceptable engineering. An implied-fixed gap is this mission's core failure in
|
||||
> a new costume** — a verification artifact that verifies nothing, with a green to prove it.
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-07-31) from D-19. Origin: the RM-01 symlink manifest could not be
|
||||
> made tamper-evident against a same-UID actor (CWE-345), because the manifest and its marker share one
|
||||
> writable tree. The implementing seat **escalated rather than relabelling self-authentication as
|
||||
> tamper-resistance** — the corollary above firing on its first real adversarial test, on the cheapest
|
||||
> seat in the loop. Residual risk bound to **RM-59** (`depends_on: RM-12, RM-21, RM-25`), where the
|
||||
> choke-point executor and spine verify from _outside_ the worktree's authority.
|
||||
|
||||
## Decision record (authoritative, immutable)
|
||||
|
||||
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||
- Site + `annotations.json`: `jarvis-brain/docs/postmortem-spec/site/` (committed, origin/main).
|
||||
- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`.
|
||||
- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04).
|
||||
- MACP wiring scout (verdict c=STRANDED): [`MACP-WIRING-SCOUT.md`](./MACP-WIRING-SCOUT.md) (copied into this dir; TODO discharged). Its findings are sound; its _recommended wire-in point_ is superseded by DECISION-1.
|
||||
|
||||
## The plan — 15 proposals collapse to 4 builds + hygiene
|
||||
|
||||
| Build | Absorbs | What it is |
|
||||
| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. ⚠ **Target CORRECTED 2026-07-31 (DECISION-1, Mos):** a new production Node `TaskExecutor` on the **live** dispatch path (`packages/mosaic` launch + `packages/coord`), which Coord/Forge/live-dispatch submit through. **NOT** `mosaic_orchestrator.py::run_single_task` — that controller is `"enabled": false` and references a dispatcher absent from this checkout; wiring it would strand the executor, reproducing this mission's own disease. The Python rail is **deleted**, not ported. Both planners reached this independently. |
|
||||
| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. |
|
||||
| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. |
|
||||
| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. |
|
||||
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 1–4 hold. |
|
||||
|
||||
## The finding that sets the cost
|
||||
|
||||
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
|
||||
exist; the Mission Control PRD exists; PG + Redis already run in-stack. Three duplicate MACP islands, an
|
||||
orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retire, NOT greenfield. "Finish, don't re-spec."**
|
||||
|
||||
## Sequencing (skeleton — adversarial decomposition refines this)
|
||||
|
||||
1. **Spine + choke-point service** (builds 1+2) — foundation; unlocks MISSION/STATE/AUDIT/WRAPPER/QUEUE at one integration point.
|
||||
(Per DECISION-1, a P0 phase of provable-gate + activation work precedes this; see `TASKS.md` §3.)
|
||||
2. **Rotation daemon** (build 3) on that spine — the drift fix proper.
|
||||
3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux.
|
||||
4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness.
|
||||
|
||||
- **Cross-cutting retirements:** flat-file orchestration tracking (hard cutover to DB), the 3 duplicate MACP islands, the silent `MOSAIC BYPASS`.
|
||||
|
||||
## Standing directives (Jason, 2026-07-31)
|
||||
|
||||
- **Dogfooding:** validate EACH fix against the live fleet failure that motivated it. Seed acceptance tests:
|
||||
Pi brick (RECOVERY), scout-bounce (INBOX/FLEET), gate-6 inert + #1019 recursion (QUEUE), identity drift
|
||||
(WRAPPER), auto-sync sweep (WORKFLOW), #1018 stale-consumed (INBOX). The fleet is its own test bed.
|
||||
- **Orchestration tracking → DB**, hard cutover ("rip off the bandaid"), NO flat-file interim. jarvis-brain
|
||||
PDA flat-files untouched. Current flat-file tracking runs as-is/unhardened until DB tracking is real, then one clean replace.
|
||||
- ⚠ **QUALIFIED 2026-07-31 (DECISION-2, Mos):** the DB spine **must NOT be a single-point hard-stop.**
|
||||
A broker-independent / degraded mode **and** a rehearsed rollback artifact are **design requirements**
|
||||
(P-RECOVERY-001), binding now on RM-12, RM-13, RM-23, RM-36 and RM-53. This **supersedes** the earlier
|
||||
orchestrator recommendation to pre-commit "no DB ⇒ the fleet stops" — that answer is _not_ on record.
|
||||
Only the specific availability _target_ remains open, queued for Jason; it does **not** block current work.
|
||||
|
||||
## The 15 decisions (one-line; full rationale in the checkpoint)
|
||||
|
||||
1. **P-ACTIVATION-001** accept — transactional CLI+hooks+broker+version release; block launch on skew, fail-SAFE.
|
||||
2. **P-AUTHORITY-001** MODIFY — structured authenticated inbox; envelope carries comms-PROTOCOL version; version the protocol not participants; N-version window.
|
||||
3. **P-LIFECYCLE-001** accept — rotation not recursive compaction; pre-empt at token threshold; enforcer = deterministic coordinator; = finish Mission Control Plane.
|
||||
4. **P-MISSION-001** accept — bind lanes to mission+task ledger; convention exists, ENFORCEMENT is the gap; mission+tasks → DB spine (hard cutover).
|
||||
5. **P-QUEUE-001** accept — repair queue transport + exit-asserting non-null-case tests (gate-6 was INERT fleet-wide; #1019 fix recursed the same bug).
|
||||
6. **P-STATE-001** accept — typed claims (source/confidence/TTL) not prose blob; MACP typed record; integrity fail-closed HMAC; don't fork a 4th island.
|
||||
7. **P-AUDIT-001** accept — MACPEvent lifecycle ledger; EXTEND enum to lifecycle events; runtime-neutral (executor-emitted); retire duplicate Python ledger.
|
||||
8. **P-WRAPPER-001** accept — identity derives from seat name + survives respawn; tri-state write outcomes MANDATORY; name safe target metadata.
|
||||
9. **P-CONTRACT-001** accept — bind session to contract hash; re-anchor on policy-change OR compaction-detected; stale generation loses authority MECHANICALLY.
|
||||
10. **P-INBOX-001** MODIFY — sole-path comms SERVICE; PG durable SoR + Redis hot queue (outbox, reconciliation sweeper); pluggable adapters; protocol-first, PG-first-then-Redis.
|
||||
11. **P-RECOVERY-001** accept — broker-independent bootstrap recovery; honest capability labeling; break-glass LOUD+AUDITED+TEMPORARY not silent permanent bypass.
|
||||
12. **P-GUIDE-001** accept — delete `/compact and continue` from orchestrator path (keep for ephemeral); removal = substitution (wire rotation trigger).
|
||||
13. **P-FLEET-001** accept — one roster-owned socket/host; quarantine unmanaged; stale-session GC; prerequisite for INBOX identity-addressing.
|
||||
14. **P-WORKFLOW-001** accept — auto-sync ALLOWLIST not denylist; worktree/lease isolation for agent docs/source; DB-tracking obviates the flat-file-sweep criterion.
|
||||
15. **P-CONFORMANCE-001** accept — fleet lifecycle harness on REAL runtime artifacts + fault injection; the 100-rotations-lossless bar is a test; target the DB substrate.
|
||||
|
||||
## Fleet operating model
|
||||
|
||||
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
|
||||
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
|
||||
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
|
||||
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
|
||||
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
|
||||
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
|
||||
@@ -1,894 +0,0 @@
|
||||
# Remediation Backlog — Reconciled Execution Plan
|
||||
|
||||
**Owner:** `mos-remediation` (sole writer). Workers read; they never modify this file.
|
||||
**Sources:** [`DECOMP-OPUS.md`](./DECOMP-OPUS.md) (robustness, 38 tasks / 8 dissents) and
|
||||
[`DECOMP-SOL.md`](./DECOMP-SOL.md) (pragmatic, 25 tasks / 10 defers / 7 dissents), produced
|
||||
**independently** — neither planner read the other. Charter: [`MISSION.md`](./MISSION.md).
|
||||
**Status:** EXECUTING — all three blocking decisions RULED by Mos on 2026-07-31 (§5). **RM-01 is
|
||||
dispatched.** RM-03 is held pending Jason's disposition of PR #1023; nothing else is blocked.
|
||||
|
||||
> **Provenance of the inputs (both clean).** `planner-opus` ran in a fresh session throughout.
|
||||
> `planner-sol` initially began work at 64.3% dirty context despite a brief instructing it to reset;
|
||||
> that run was **interrupted and discarded before it produced any output**, the seat was reset
|
||||
> out-of-band to 0.0%, and the brief was re-dispatched. `DECOMP-SOL.md` is the product of the clean
|
||||
> run only (it peaked at ~26% context). Both decompositions are therefore clean-context artifacts and
|
||||
> are weighted equally here. The discarded dirty run is banked as dogfood seed D-4 and as task RM-58 —
|
||||
> the failure it demonstrates is that _asking_ an agent to reset is not enforcement.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the two planners agreed on without collusion
|
||||
|
||||
Independent convergence is the strongest signal available here, because neither planner could see the
|
||||
other's file. Where both arrived at the same conclusion from opposite biases, I treat it as settled.
|
||||
|
||||
| # | Convergent finding | OPUS | SOL |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------------- | ------------------- | -------------- |
|
||||
| C1 | **The charter's wire-in point is wrong.** Do NOT wire the choke point into `mosaic_orchestrator.py::run_single_task`. | D2 (headline) | Dissent 1 |
|
||||
| C2 | P0 hygiene/gate work must precede the spine, not follow it. | D1, phase P0 | G0, SOL-01/02 |
|
||||
| C3 | No new deployable microservice; the executor is a library + the coord daemon. | implicit throughout | Dissent 3 |
|
||||
| C4 | Comms adapters beyond tmux are out of scope for this mission. | D7 | DEFER 4/5/6 |
|
||||
| C5 | The "100 rotations lossless" bar is a late conformance gate, not an early tax. | D4 | Dissent 7 |
|
||||
| C6 | Redis is a derived hot path, never an authority; PG commits first. | R-013, R-054 | SOL-11, SOL-21 |
|
||||
| C7 | Reuse `packages/coord`; do NOT revive the untracked `apps/coordinator` residue. | R-042 | SOL-15 AC5 |
|
||||
|
||||
**C1 is the single most consequential output of this exercise.** The charter (`MISSION.md`) and my
|
||||
kickoff instruction both name `mosaic_orchestrator.py::run_single_task:126-276` as the integration
|
||||
point. Both planners independently rejected it on the same evidence: that controller is
|
||||
`"enabled": false` (`.mosaic/orchestrator/config.json:2`) and references a dispatcher path
|
||||
(`tools/macp/dispatcher/pi_runner.ts`) that does not exist in this checkout. Wiring the new choke
|
||||
point into a disabled rail produces **a stranded executor — the identical built-but-unwired disease,
|
||||
one layer up, that would look "done" in a PR.** The live paths are
|
||||
`packages/mosaic/src/commands/launch.ts` and `packages/coord/src/runner.ts`.
|
||||
This contradicted the charter and was escalated as DECISION-1 — **now RULED in the planners' favour by
|
||||
Mos (§5)**. The corrected target is a new production Node `TaskExecutor` on the live dispatch path
|
||||
(`packages/mosaic` launch + `packages/coord`) that Coord/Forge/live dispatch submit through; the
|
||||
Python rail is deleted, not ported.
|
||||
|
||||
---
|
||||
|
||||
## 1a. ★ KEYSTONE DOGFOOD CASE — an inert gate that erased its own evidence
|
||||
|
||||
**A merged commit shipped past `pnpm format:check` — and then the evidence quietly erased itself.**
|
||||
|
||||
Verified chain (blob-level, under the repo's own prettier config, at the file's real path):
|
||||
|
||||
| commit | state of `packages/mosaic/framework/tools/orchestrator/README.md` |
|
||||
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `b79336a8` — **merged PR #868** | blob `3ee7f104` — **FAILS** `pnpm format:check` |
|
||||
| `48fd1df2` — merged PR #872 (unrelated: ci-queue-wait 404 handling) | blob `3d3bb132` — passes; incidentally reformatted by that PR's `lint-staged` |
|
||||
| current `origin/main` (`06e0d403`) | passes — **the gate now looks green** |
|
||||
|
||||
So: PR #868 merged a file that fails a required gate ⇒ **the CI format gate did not block it.** The
|
||||
gate was inert for that merge. Then an unrelated later PR's pre-commit hook reformatted the file as a
|
||||
side effect, so `main` went green again **without anyone ever learning the gate had failed to fire.**
|
||||
|
||||
> **Correction on record:** my first report to Mos said "format:check is RED on main _now_." That was
|
||||
> true of the `main` my checkout was pinned to (`b79336a8`) and is **no longer true of current `main`**,
|
||||
> which advanced mid-session. The inert-gate finding itself is unchanged and verified; only its
|
||||
> present-tense framing was wrong. The hygiene PR therefore carries the `.prettierignore` fix only —
|
||||
> the README needs no fix today.
|
||||
|
||||
### Third live instance, same class — the queue guard, hit by this orchestrator
|
||||
|
||||
Running the **mandated** pre-push guard during TASK-0:
|
||||
|
||||
```
|
||||
$ ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||
[ci-queue-wait] platform=gitea purpose=push branch=main sha=06e0d403…
|
||||
[ci-queue-wait] state=unknown purpose=push branch=main
|
||||
$ echo $? → 0
|
||||
```
|
||||
|
||||
**Two distinct defects in one tool**, both feeding RM-03:
|
||||
|
||||
1. **Wrong exit** — `state=unknown` ⇒ `exit 0`. The defect at `ci-queue-wait.sh:282-288` that OPUS
|
||||
documented and that PR #1023 is parked on. A required gate returned PASS on an indeterminate result.
|
||||
2. **Wrong branch** — it evaluated `branch=main`, not the branch actually being pushed. Even a
|
||||
correctly-exiting guard would have been answering the wrong question.
|
||||
|
||||
**Standing doctrine (Mos):** until RM-03 lands, a green from this guard carries **zero information**
|
||||
and must not be cited as merge evidence. Rely on reviewer clearance + real CI.
|
||||
|
||||
Three independent live instances in a single session — format gate, agent context reset, queue guard —
|
||||
is the class confirmed, not anecdote.
|
||||
|
||||
### D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically
|
||||
|
||||
`rev-974` blocked PR #1027 a second time. **The defect was not in the code — it was in this file**, at
|
||||
D-18's entry, written by the orchestrator.
|
||||
|
||||
Two faults, both mine:
|
||||
|
||||
1. **D-18's AC2 restatement omitted the scope clause** that D-19 later established as mandatory
|
||||
("within an accidental/independent-mutation threat model").
|
||||
2. **D-18 asserted that the tampered-manifest control turns integrity "from a claim into a property."**
|
||||
It does not, and _cannot_. That sentence was written **before** D-19 proved the property impossible
|
||||
at this layer, and was never revised when D-19 landed.
|
||||
|
||||
**The reviewer did not merely read it — it disproved it.** It performed a **same-UID consistent
|
||||
manifest + marker rewrite**, and **preflight passed**. My documented claim was falsified by experiment.
|
||||
Code, README, scratchpad and PR body all stated both threat-model directions correctly; **this file was
|
||||
the only place still overclaiming.**
|
||||
|
||||
**This is two banked findings firing on the orchestrator at once:**
|
||||
|
||||
- **The integrity-claim corollary** — I wrote an integrity _claim_ in the voice of an integrity
|
||||
_property_, in the very document that defines the rule against doing so.
|
||||
- **D-14 (propagation)** — D-19 superseded D-18's assertion. I propagated the consequence into the
|
||||
charter and the delivery conditions, but **not back into D-18 itself.** A ruling that fails to
|
||||
propagate _backwards_ into the finding it supersedes is the same defect as one that fails to
|
||||
propagate forwards, and I did not audit for that direction.
|
||||
|
||||
**Corrected in place**, with the original wording quoted and the empirical disproof recorded, rather
|
||||
than silently rewritten — the same standard demanded of any restated criterion.
|
||||
|
||||
**Requirement on RM-02 (fifth clause).** Documentation asserting a _security or integrity_ property is
|
||||
itself a claim requiring a negative control. Where a document states "X is guaranteed", the registry
|
||||
must hold a case that **fails if X is not guaranteed** — and that case must have been observed red.
|
||||
**Prose is not exempt from the mission's own evidentiary standard**, and prose in the _governing_
|
||||
document least of all: it is the artifact most likely to be quoted as authority long after the code has
|
||||
moved on.
|
||||
|
||||
**Reviewer credit.** rev-974 was briefed that its highest-priority check was "confirm the PR claims no
|
||||
more than it can deliver, and a softened or omitted boundary is a finding even though the code works."
|
||||
It applied that instruction **to the orchestrator's own governing document** and produced an experiment
|
||||
to settle it. That is the review standard this mission is trying to make ordinary.
|
||||
|
||||
### D-19 — an integrity property that cannot exist at the layer it was specified
|
||||
|
||||
Implementing D-18's manifest, the seat + a Codex security review reached **CWE-345**: the symlink
|
||||
manifest and the source-hash marker both live in the **same same-UID writable generated tree**, so an
|
||||
actor with that UID can plant a rogue link, regenerate _both_, retain the fingerprint, and pass. **No
|
||||
local cryptographic construction fixes self-authentication** without a key outside that actor's
|
||||
authority; relocating the marker changes the path, not the authority.
|
||||
|
||||
The seat **escalated rather than describing self-authentication as tamper-resistant** — the explicit
|
||||
failure mode the charter corollary demands. That is the corollary working, on its first real test.
|
||||
|
||||
**Ruling — Option A: scope AC2 to accidental / independent / stale mutation; retain the design.**
|
||||
Rationale, recorded so it can be challenged:
|
||||
|
||||
1. **The undefendable boundary is not the weak link.** An actor with same-UID write can already edit the
|
||||
source, the tests, `scripts/preflight.mjs` itself, and `.husky/*`. If they have that, _nothing_ in the
|
||||
local checkout is trustworthy — hardening the manifest buys no real security while **implying
|
||||
protection that does not exist**, which is worse than the gap.
|
||||
2. **What AC2 is actually for.** These checks exist because a five-month-stale `.next` produced 19
|
||||
phantom `TS2307` errors indistinguishable from real ones (**D-5**). That is staleness, drift and
|
||||
foreign residue — and against that class the design demonstrably works.
|
||||
3. **A real trust anchor arrives later, from this mission's own architecture.** An anchor must live
|
||||
outside the actor's authority; for a fleet running as one user that means a separate service —
|
||||
precisely the **choke-point executor + PG spine** of Builds 1–2, which verify outside the worktree's
|
||||
authority. Hand-rolling key distribution for a local preflight now would duplicate that work badly.
|
||||
4. Option C (structural policy, no manifest) is strictly worse — it cannot detect a **removed** expected link.
|
||||
|
||||
**Option A is acceptable only with honest labelling**, or it becomes the disease it is meant to cure.
|
||||
Conditions (last two added/sharpened by Mos):
|
||||
|
||||
- Threat model stated verbatim in the code **and** the PR; the words _tamper-proof / tamper-evident /
|
||||
secure_ **barred** from that context; the scope carried in AC2's restatement; every control kept
|
||||
RED-first including manifest-only tamper.
|
||||
- **State the boundary in BOTH directions.** Not only what it does _not_ defend (same-UID write; no
|
||||
local construction can) but, beside it, what it **does** defend: accidental / independent / stale /
|
||||
foreign-residue mutation — the **D-5** class it was born from (the five-month `.next` and its 19
|
||||
phantom `TS2307`s). _A reader who sees only the negative dismisses the check as worthless; one who
|
||||
sees only the positive over-trusts it. Both together is the honest artifact._
|
||||
- **The residual risk is a HARD TRACKED DEPENDENCY EDGE, not a comment.** It is **RM-59**, owned by the
|
||||
choke-point executor + spine work (`depends_on: RM-12, RM-21, RM-25`), and the AC2 scope note must
|
||||
cite that id. _"Record where the real guarantee comes from" only holds if the record is a live
|
||||
dependency someone must close._ **A documented gap with no owner becomes a permanent gap that reads
|
||||
as intentional.**
|
||||
|
||||
**The generalizable rule.** When a required property **cannot exist at the layer where it was
|
||||
specified**, the honest moves are: implement what the layer _can_ guarantee, **state the boundary
|
||||
precisely**, and record where the real guarantee will come from. **A known gap that is written down is
|
||||
acceptable; a gap that is implied fixed is not.** Silence here would have shipped a verification
|
||||
artifact that verifies nothing — with a green to prove it.
|
||||
|
||||
### D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation
|
||||
|
||||
Implementing D-17's fix surfaced a conflict **between** pre-registered criteria:
|
||||
|
||||
- **AC2** (as written) — reject symlinked generated state.
|
||||
- **AC4** — the canonical `pnpm -w build` succeeds and leaves no residue.
|
||||
|
||||
Verified independently rather than taken on report: `apps/web/next.config.ts:4` sets
|
||||
`output: 'standalone'`, and the built tree contains **42 legitimate pnpm dependency symlinks** under
|
||||
`.next/standalone/node_modules`. A blanket descendant-symlink rejection makes the canonical build fail
|
||||
its own preflight with exit 43. **AC2 read literally is unsatisfiable alongside AC4 under this
|
||||
configuration**, and nothing short of building the tree would have revealed it.
|
||||
|
||||
**Third distinct failure mode of a pre-registered check set**, completing the chain:
|
||||
|
||||
| finding | a pre-registered check set can be… |
|
||||
| ------- | ------------------------------------------------------------------ |
|
||||
| D-8 | **wrong** — a check that does not test what it claims |
|
||||
| D-17 | **incomplete** — green while a criterion's requirement is untested |
|
||||
| D-18 | **internally inconsistent** — two criteria that cannot both hold |
|
||||
|
||||
The implementing seat escalated instead of silently picking a winner. That matters: **quietly resolving
|
||||
a conflict between pre-registered criteria destroys the point of pre-registering them** — the registration
|
||||
exists so that changes of meaning are auditable rather than absorbed.
|
||||
|
||||
**Resolution (orchestrator ruling).** Approved a **build-certified symlink manifest**: `.next` itself is
|
||||
still rejected as a symlink; descendants are rejected unless _exactly_ certified by a manifest the build
|
||||
publishes atomically. Strictly **stronger** than blanket rejection — it also catches a **retargeted**
|
||||
symlink, which blanket rejection cannot distinguish from a legitimate one.
|
||||
|
||||
**AC2 restated (recorded, not absorbed).** _Generated state must reject `.next` itself being a symlink
|
||||
or non-directory, and must reject any descendant symlink not exactly certified by the build manifest —
|
||||
added, removed, retargeted, or manifest-only-tampered all fail with exit 43 — **within an accidental /
|
||||
independent-mutation threat model.**_
|
||||
|
||||
> ⚠ **This entry is superseded in part by [D-19](#d-19). Do not read D-18 standalone.** The scope clause
|
||||
> above is load-bearing: the design **cannot** defend against a same-UID actor, which can rewrite the
|
||||
> manifest and the marker consistently (CWE-345). D-18 was written **before** that impossibility was
|
||||
> established.
|
||||
|
||||
**Hardening required before this counts.** The manifest is itself generated state, so **a manifest
|
||||
writable by whoever plants a rogue symlink certifies the attack** — that is the one way this design
|
||||
fails. It must sit inside the same ownership/fingerprint envelope, published atomically via the existing
|
||||
marker mechanism, with negative controls **observed red first** for: added, removed, retargeted,
|
||||
**manifest-only-tampered**, plus a positive control that the canonical build passes.
|
||||
|
||||
> ⚠ **CORRECTED (D-20).** This paragraph originally ended: _"without it, integrity is a claim rather
|
||||
> than a property."_ **That overclaimed**, by implying the control makes integrity a _property_. It does
|
||||
> not, and cannot. The manifest-only-tamper control detects **independent** mutation of the manifest;
|
||||
> it confers **no authenticity** against an actor who rewrites manifest _and_ marker together.
|
||||
> `rev-974` disproved the original wording empirically — a same-UID consistent manifest+marker rewrite
|
||||
> **passed preflight**. Integrity here remains a scoped **drift-detection** property, never an
|
||||
> authenticity one. See D-19 and the charter principle on properties that cannot exist at their
|
||||
> specified layer.
|
||||
|
||||
**Requirement on RM-02 (fourth clause).** The registry must detect **conflicts between registered
|
||||
criteria**, not only wrongness and coverage. Two criteria that cannot simultaneously hold is a registry
|
||||
defect discoverable by construction — and when a criterion is restated, the registry must retain the
|
||||
original text, the restatement, and the reason, so the evolution stays auditable.
|
||||
|
||||
### D-17 — a pre-registered criterion passed a green suite without being satisfied
|
||||
|
||||
`rev-974` returned **CHANGES REQUESTED** on PR #1027 with one blocking finding, and it is the sharpest
|
||||
instance of the session's theme because it occurred **inside our own verification machinery**.
|
||||
|
||||
**AC2** was pre-registered before any code was written, and explicitly required that **symlinked
|
||||
generated state be rejected**. The implementation does not do it:
|
||||
|
||||
```sh
|
||||
ln -s /etc/hosts apps/web/.next/reviewer-symlink
|
||||
pnpm preflight # → "checkout preflight passed", exit 0
|
||||
# → required: generated-state exit 43
|
||||
```
|
||||
|
||||
The acceptance suite was **21/21 green** throughout. Confirmed independently rather than relayed:
|
||||
`scripts/preflight.mjs:82-92` rejects symlinks on the **source** path; `:28` merely _skips_ symlinked
|
||||
directories rather than rejecting them; and the **generated-state** path at `:141-163` `lstat`s and
|
||||
checks `uid` (ownership) but **never** calls `isSymbolicLink()`. The suite's only symlink cases
|
||||
(`preflight.test.mjs:59`, `:115`) cover the turbo binary and a _source_ file. No generated-state case
|
||||
exists anywhere.
|
||||
|
||||
**So: criterion pre-registered, suite green, requirement unmet.** Nobody was careless — the coverage gap
|
||||
is _invisible from a green_, which is the entire problem.
|
||||
|
||||
**This sharpens D-8 rather than repeating it.** D-8 established that pre-registration does not confer
|
||||
_correctness_ (a check can be wrong when written). D-17 establishes the adjacent failure:
|
||||
**pre-registration does not confer _coverage_** — a suite can be green, and every registered criterion
|
||||
can appear satisfied, while a criterion's actual requirement is untested. The two together mean a
|
||||
registry of checks needs **two** properties, not one: each check must be _right_, and the set must
|
||||
_actually exercise_ what it claims.
|
||||
|
||||
**Requirement on RM-02 (third clause).** The registry must bind each acceptance criterion to the
|
||||
**specific case that exercises it**, and prove that case red before trusting its green. A criterion with
|
||||
no case that can fail for _that criterion's stated reason_ is unregistered in substance however it
|
||||
appears in the manifest. This is mutation testing pointed at the **criterion-to-case mapping**, not
|
||||
merely at the gate.
|
||||
|
||||
**Credit where due:** the reviewer also declined to re-run AC8, stating plainly that the PR carried it
|
||||
forward with no runnable command rather than silently substituting a different boundary test. That is
|
||||
the D-8 clause working a second time, in the same review that produced D-17.
|
||||
|
||||
### D-16 — the local test gate and the CI test gate disagree by environment
|
||||
|
||||
Mos flagged a shape worth chasing: if `pnpm test` exits non-zero on a _pre-existing_ guard, then either
|
||||
`main` is red and merges step around it (the #868 shape again), or CI does not run that path. **Both
|
||||
branches turned out wrong, and the truth is a third thing.** Established by running it, not by asking:
|
||||
|
||||
CI runs **exactly** `pnpm test` (`.woodpecker/ci.yml`, `test` step) — the same command. So the path _is_
|
||||
exercised. Yet:
|
||||
|
||||
| environment | result |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| CI container | `test` step **green** (#2158, #2167) |
|
||||
| this host, clean worktree | **exit 97** — `WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated on bash 5.2.15(1)-release … expected [3 4], probe reported [3 5] (#973)`, after `PASS=18 FAIL=0` |
|
||||
| this host, main checkout | **exit 1** — a _different_, second defect (below) |
|
||||
|
||||
The guard is **environment-dependent**: it aborts on this host's bash and not in CI's container. `git
|
||||
diff origin/main...` confirms PR #1027 touches **zero** files under `packages/mosaic`, so the guard is
|
||||
genuinely pre-existing and unrelated — **f10-coder's report was accurate in every particular**, and
|
||||
`main` is equally affected on this host.
|
||||
|
||||
**So it is not "merges step around a red" — it is worse in one specific way: the local gate and the CI
|
||||
gate do not agree about what passing means.** No agent on this host can obtain a green `pnpm test` at
|
||||
all, on any branch, including `main`. A gate an operator cannot run is a gate that only CI enforces,
|
||||
and a gate only CI enforces cannot be a pre-push gate. This is the hermeticity/portability class
|
||||
already live as #1007 (PR #1024).
|
||||
|
||||
**Second, independent defect found while establishing the above.** In the main checkout the same
|
||||
package fails differently — exit 1 — because a test **scans the working tree** and asserts on files it
|
||||
finds, picking up `apps/coordinator/venv/**` (third-party `site-packages`: `pi = math.pi` in `rich`,
|
||||
`setuptools`, `mypy`). **A test whose result depends on untracked files present in the tree is not
|
||||
hermetic.** This is the _same_ contamination source that made `pnpm format:check` unpassable (D-1/D-7
|
||||
hygiene) — one untracked foreign tree silently breaking two independent gates.
|
||||
|
||||
**Requirements.** RM-01/RM-04: a gate must produce the same verdict on a developer host and in CI, or
|
||||
declare loudly that it cannot run here — never diverge silently. RM-02 registers both as cases: the
|
||||
environment-divergence guard, and a hermeticity control asserting a suite's verdict is unchanged by the
|
||||
presence of untracked directories. Coordinate with #1007/#1024 rather than opening a third lane.
|
||||
|
||||
**Ownership (Mos, 2026-07-31).** The hermeticity fix **is** PR #1024, which sits in **Jason's parked
|
||||
delivery stack** — so, like #1023, its disposition is Jason's. Marked `SUPERSEDED-PENDING-JASON`
|
||||
alongside #1023. D-16 strengthens the urgency but does not transfer ownership: **we do not open a third
|
||||
lane on a parked PR.** The one-line escalation for Jason: _two independent gates
|
||||
(`format:check`, `pnpm test`) were broken by a single untracked directory, and a third
|
||||
(`pnpm test`) disagrees between host and CI — non-hermetic gates make every green host-dependent._
|
||||
|
||||
**Sharpened statement of the class (Mos).** A pre-push gate an operator _cannot run locally_ is a gate
|
||||
only CI enforces — so pointing `.husky/pre-push` at it **misrepresents where the gate lives**. Combined
|
||||
with the shared root cause across two gates, the finding is: **non-hermetic gates make every green
|
||||
host-dependent.** A gate that only appears to pass depending on which host runs it is this mission's
|
||||
exact subject, one meta-level up.
|
||||
|
||||
**#1027 disposition (Mos):** proceeds on **CI-green**. CI is the authoritative gate; the local exit-97
|
||||
is a known host-specific guard abort, irrelevant to the merge decision.
|
||||
|
||||
### D-15 — token scope is not repository permission (a THIRD capability layer)
|
||||
|
||||
`f10-coder` was provisioned with `gitea-mosaicstack-f10-coder.token`, scopes `write:repository` +
|
||||
`write:issue`, and the mint was verified by "repo access returns 200". It then failed to push:
|
||||
|
||||
```
|
||||
remote: error: User permission denied for writing.
|
||||
remote: error: pre-receive hook declined
|
||||
```
|
||||
|
||||
Verified objectively rather than inferred (per the charter principle):
|
||||
|
||||
| probe | result |
|
||||
| ------------------------------------------------------ | ------------------------------------------- |
|
||||
| `GET /repos/mosaicstack/stack/collaborators/f10-coder` | **404** — not a collaborator |
|
||||
| repo permissions as seen by **its own token** | `admin: false`, `push: false`, `pull: true` |
|
||||
|
||||
**Capability has at least three independent layers, and satisfying two proves nothing about the third:**
|
||||
|
||||
1. **Token file exists** → raw-API authentication works (D-11b).
|
||||
2. **`tea` login exists** → tea-dependent wrapper paths work (D-13).
|
||||
3. **Repository permission granted** (collaborator/team membership) → _writes_ are actually authorised.
|
||||
|
||||
A token can carry `write:repository` scope and still be refused, because **scope bounds what a token
|
||||
may attempt; repository permission decides what the user may do.** They are different systems.
|
||||
|
||||
**This is the charter principle failing on the very check meant to confirm capability.** The mint was
|
||||
validated by an HTTP 200 on a _read_. A 200 proves reachability; it does not prove the property that
|
||||
was required, which was **write**. Both the provisioner and I accepted it — the same
|
||||
`written-unverified` treated as `verified` as D-12, one layer up, on a check whose entire purpose was
|
||||
verification.
|
||||
|
||||
**Requirements.** RM-50's pre-dispatch capability check must probe the **effective permission for the
|
||||
operation intended** — for push authority, assert `permissions.push == true` as that seat, not token
|
||||
existence and not a 200 on a read. RM-04's registry reconciliation covers all three layers, with a
|
||||
must-fail control for each. A capability check that cannot fail on a seat lacking write permission is
|
||||
itself an inert gate.
|
||||
|
||||
### D-14 — a ruled decision did not propagate to the authoritative record
|
||||
|
||||
DECISION-1 (the corrected choke-point wire-in target) was ruled by the coordinator and applied to
|
||||
`TASKS.md`. **`MISSION.md` — the charter, the document a cold-starting seat reads first — kept the
|
||||
superseded target for hours.** It was flagged `CONTESTED` in a board note, then the ruling landed and
|
||||
nobody edited the charter. A seat resuming from the charter would have read the _rejected_ target as
|
||||
authoritative and wired the choke point into a disabled rail — the precise failure the ruling existed
|
||||
to prevent.
|
||||
|
||||
Caught by hand, during an unrelated edit. Nothing would have caught it otherwise.
|
||||
|
||||
**This is P-MISSION-001 turned on ourselves.** The mission's own thesis is that convention exists and
|
||||
_enforcement_ is the gap: a decision that lives in a chat ruling and a board note, but not in the
|
||||
source of truth, has not actually been made — it has been _agreed_. The two are different, and the
|
||||
difference is exactly what this mission is about.
|
||||
|
||||
> ⚠ **AMENDED by D-20 — propagation is BIDIRECTIONAL.** As first written, this requirement was read by
|
||||
> both the orchestrator and the coordinator as _forward_ propagation only: a ruling reaches the
|
||||
> documents that state the new rule. **D-20 proved that insufficient.** When D-19 superseded part of
|
||||
> D-18, the consequence propagated forward into the charter and the delivery conditions but **never
|
||||
> backward into D-18 itself**, which went on asserting a withdrawn claim — and a reviewer disproved it
|
||||
> by experiment. **A supersession must update BOTH the documents that render the new rule AND the
|
||||
> finding it retires, with the retired wording quoted rather than deleted.** Backward propagation is
|
||||
> the same defect as forward; neither of us audited that direction until it bit.
|
||||
|
||||
**Requirement (not merely a fix).** A ruled decision must propagate **mechanically** to the
|
||||
authoritative record; it must not depend on someone remembering to edit a second file. Concretely, once
|
||||
mission state is DB-backed (RM-53 / the P-MISSION cutover):
|
||||
|
||||
- a decision is a **record**, not prose duplicated across documents;
|
||||
- documents _render_ decisions rather than restating them, so there is one place to be wrong;
|
||||
- and where duplication is unavoidable, a check asserts the authoritative record and the derived
|
||||
document agree — with a must-fail control proving divergence is detected.
|
||||
|
||||
Until then, the interim rule: **the same commit that records a ruling updates every document that
|
||||
states it — and every finding it supersedes.** Interim rules are exactly what the DB cutover exists to replace.
|
||||
|
||||
**The rule found a second instance within minutes of being written.** Auditing the charter against all
|
||||
rulings to date (rather than waiting to be bitten again) surfaced that **DECISION-2 had also not
|
||||
propagated**: `MISSION.md`'s standing directives still stated the DB hard-cutover with no mention of
|
||||
Mos's binding qualification that _the spine must not be a single-point hard-stop_ (degraded mode +
|
||||
rollback artifact required). A seat reading the charter would have designed toward an availability
|
||||
posture the coordinator had explicitly rejected — and would have found the superseded
|
||||
"no DB ⇒ the fleet stops" recommendation nowhere contradicted. Now corrected in place.
|
||||
|
||||
**Two un-propagated rulings out of two rulings that touched charter text.** The propagation gap is not
|
||||
an oversight that happened once; without a mechanism it is the _default outcome_. That is the argument
|
||||
for making this a requirement rather than a discipline.
|
||||
|
||||
### D-13 — two credential registries that can disagree (why the `--draft` fallback fired at all)
|
||||
|
||||
Diagnosing D-12's root cause surfaced a distinct defect. There are **two parallel credential
|
||||
registries**, and capability in one does not imply capability in the other:
|
||||
|
||||
| registry | contents for identity `mos-dt-0` on `mosaicstack` |
|
||||
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
||||
| token files — `~/.config/mosaic/secrets/gitea-tokens/` | `gitea-mosaicstack-mos-dt-0.token` **EXISTS** |
|
||||
| `tea login list` | **NO** `mosaicstack` login for `mos-dt-0` (only `mosaicstack-mos` and `mosaicstack-rev-974`) |
|
||||
|
||||
So `get_gitea_token` succeeds and every raw-API path works, while every **tea-dependent** wrapper path
|
||||
fails its login validation and silently degrades to the API fallback — which is exactly what dropped
|
||||
`--draft`. **tea is not "stale"; the login simply does not exist for that identity.**
|
||||
|
||||
This matters beyond one flag: capability was declared authoritative by the token-file set (D-11b), but
|
||||
that registry does not govern the tea path. A seat can be _fully provisioned_ by the authoritative
|
||||
registry and still lose functionality with no error — only a warning, and only on the degraded path.
|
||||
|
||||
**Requirements.** RM-04 (activation coherence): the two registries must be reconciled — one source of
|
||||
truth, or a startup check asserting they agree, with a must-fail control proving disagreement is
|
||||
detected. RM-50: the pre-dispatch capability check must verify capability for the **path actually
|
||||
used**, not merely token-file presence.
|
||||
|
||||
**Confirmed working despite the gap** (so this is degradation, not outage): pushes, `pr-merge.sh`,
|
||||
PR/issue creation via API fallback, comment posting, and all reads. Impact is confined to
|
||||
tea-only features — `--draft`, `--labels`, `--milestone`.
|
||||
|
||||
**Reconciliation run by Mos (the manual form of RM-04's assert-agreement, done once by hand).** For
|
||||
`git.mosaicstack.dev`, the token-file registry holds **six** seats; `tea` holds logins for **two**:
|
||||
|
||||
| state | seats |
|
||||
| ------------------------------------------------ | -------------------------------------------------------- |
|
||||
| token file present, **no** mosaicstack tea login | `f10-coder`, `jarvis`, `mos-admin`, `mos-dt-0`, `pepper` |
|
||||
| token file present **and** tea login present | `rev-974` (only) |
|
||||
|
||||
**Five of six provisioned seats are silently degraded on tea-only features.** This is _systemic_, not
|
||||
a one-off — which is why the fix is registry reconciliation (RM-04) and not a per-seat mint. Minting
|
||||
one seat would clear a symptom and leave the class live.
|
||||
|
||||
Mos deliberately deferred the mint: it is not on RM-01's critical path, and additively editing shared
|
||||
`tea` config underneath running work is a change he declined to make without cause. Full remediation —
|
||||
mint the five missing logins **and** wire the startup must-fail assertion that _detects_ disagreement —
|
||||
lands as RM-04 at a non-critical seam, or immediately if any seat needs a tea-only feature to progress.
|
||||
|
||||
**Correction of record:** this supersedes D-11(b)'s claim that the token-file set is _the_ authoritative
|
||||
capability registry. It is **necessary but not sufficient**. Capability is **per-path**: the token file
|
||||
governs the raw-API path, the tea login governs the tea path, and the two can disagree silently.
|
||||
|
||||
### D-12 — a requested SAFETY flag was silently degraded, and I did not check
|
||||
|
||||
I created PR #1027 with `pr-create.sh ... -d` (draft) because it carries **partial, unproven work**.
|
||||
`tea` authentication was stale, so the wrapper fell back to its raw-API path — which cannot set draft —
|
||||
and emitted:
|
||||
|
||||
```
|
||||
Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup.
|
||||
```
|
||||
|
||||
The PR was created **not-draft**. I read the success output, saw the PR number, and moved on. I then
|
||||
reported to the coordinator that the PR was "opened as draft". **It was open, mergeable, and marked
|
||||
ready for ~25 minutes**, protected only by the words "DRAFT" and "do not merge" in its title and body —
|
||||
i.e. by prose a human might read, not by the platform control I asked for. Detected only because a
|
||||
watcher polled `draft:` and the value disagreed with my belief. Corrected by setting the `WIP:` title
|
||||
prefix (Gitea's draft mechanism); `draft: True` verified after.
|
||||
|
||||
**Three distinct failures, and the third is mine:**
|
||||
|
||||
1. **Silent degradation of a safety flag.** The fallback path dropped `--draft` and still exited 0. A
|
||||
fallback that cannot honour a _safety_ argument must fail, not proceed — degrading `--labels` is a
|
||||
nuisance; degrading `--draft` publishes unproven work as ready to merge.
|
||||
2. **The warning went to stderr and nothing consumed it.** It was correct, specific, and ignored — a
|
||||
warning nobody acts on is indistinguishable from no warning.
|
||||
3. **I did not verify the flag took effect.** I checked that the PR existed, not that it had the
|
||||
property I required. This is the mission's own thesis turned on me: **I trusted a success exit code
|
||||
over an observed state**, on exactly the class of tool this mission exists to distrust.
|
||||
|
||||
**Requirements.** RM-02: a wrapper that cannot honour a safety-relevant argument must exit non-zero —
|
||||
registered with a must-fail control asserting `--draft` on a degraded path fails rather than proceeds.
|
||||
RM-24 (tri-state write outcomes): this is precisely `written-unverified` being treated as `verified` —
|
||||
the PR write succeeded, the _requested property_ was never confirmed, and no one looked.
|
||||
|
||||
### D-11 — seat identity did not survive into git, and seat capability is invisible at dispatch
|
||||
|
||||
Two defects, one dispatch (RM-01 → `f10-coder`):
|
||||
|
||||
**(a) Identity drift — P-WRAPPER-001, reproduced on our own delivery.** The seat's commits are
|
||||
authored `mosaic-coder <[email protected]>` — the generic fallback. **You cannot tell from
|
||||
git history which seat did this work.** Recorded, not rewritten: the drift is the evidence.
|
||||
|
||||
> **Mechanism, corrected (Mos).** My original framing here was wrong, and the error was in the brief
|
||||
> before it was in the finding. `MOSAIC_GIT_IDENTITY` resolves the **token** (which per-slot credential
|
||||
> the wrappers act with). The **commit author** comes from `git config user.name` / `user.email`, which
|
||||
> is a **separate setting** — it fell back to the generic value because nothing set it. Exporting the
|
||||
> identity could never have fixed authorship. **My worker brief instructed only the export, so the
|
||||
> seat did exactly what it was told and the commits were still mis-attributed.**
|
||||
>
|
||||
> **The requirement is coherence: token and authorship must agree.** A seat acting with
|
||||
> `gitea-mosaicstack-f10-coder` must also commit as `f10-coder <[email protected]>`.
|
||||
> Either half alone is identity drift — one produces the right credential with the wrong author, the
|
||||
> other the reverse. That coherence _is_ P-WRAPPER-001, and it belongs in seat setup, not in prose
|
||||
> instructions a seat may follow correctly and still end up wrong.
|
||||
|
||||
**(b) Capability opacity.** Nothing at dispatch time revealed that `f10-coder` had no credential for
|
||||
the target provider. Per-slot tokens live at `~/.config/mosaic/secrets/gitea-tokens/`; the seat holds
|
||||
`gitea-usc-f10-coder` but not `gitea-mosaicstack-f10-coder`. This surfaced only when the seat failed
|
||||
**mid-task, after ~$9 and 69% of its context.** The orchestrator (me) selected a seat without any way
|
||||
to check it could act on the target repo — and there was no way to check.
|
||||
|
||||
`get_gitea_token` behaved **correctly**: it refused to fall through and borrow another slot's token,
|
||||
failing loud precisely to protect gate-16 attribution. The tooling was right; the _dispatch-time
|
||||
information_ did not exist.
|
||||
|
||||
**This is P-RECOVERY-001's "honest capability labeling" applied to seats rather than services.** A seat
|
||||
should declare what it can actually do — which providers, which repos, which credentials — and that
|
||||
declaration must be **checkable before dispatch**, not discovered by failure after the budget is spent.
|
||||
|
||||
**Requirements.**
|
||||
|
||||
- **RM-04 (activation coherence)** gains the identity-binding half: seat setup must set **both** the
|
||||
token identity **and** `git config user.name`/`user.email`, coherently. Verified by an
|
||||
exit-asserting test that makes a commit and asserts its author — never assumed from an instruction
|
||||
in a brief.
|
||||
- **RM-50 (roster ownership)** gains per-seat capability declaration plus a **pre-dispatch capability
|
||||
check**. Mos (who owns provisioning) confirms the check is mechanically trivial: **capability is
|
||||
token-file existence.** Before dispatching seat `X` to provider `Y`, test that
|
||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists; if absent, provision it or pick a
|
||||
provisioned seat. **The token-file set is the authoritative capability registry.** A one-second check
|
||||
would have replaced a mid-task failure that cost ~$9 and 69% of a seat's context.
|
||||
|
||||
### D-10 — the queue guard's failure modes are exactly backwards
|
||||
|
||||
`ci-queue-wait.sh` — a **required** pre-push/pre-merge gate — was observed this session doing both of
|
||||
these:
|
||||
|
||||
- **Fails OPEN on an unknown result.** `state=unknown ⇒ exit 0`, five times, during real pushes and
|
||||
real merges. It also evaluates `branch=main` rather than the branch being acted on.
|
||||
- **Fails CLOSED on credential resolution.** In a worker seat it aborted with
|
||||
`Gitea token not found`, hard-blocking a legitimate push of completed, tested work. The worker
|
||||
correctly stopped (Constitution gate 8). The identical command run from that worker's _own worktree_
|
||||
in another shell succeeded, so the checkout and remote were fine — the difference was the worker's
|
||||
process environment.
|
||||
|
||||
**A gate that waves through work it never checked, and blocks work that is ready, has its failure
|
||||
modes inverted.** Availability failures (cannot reach the provider, cannot resolve a credential)
|
||||
should degrade to a loud, auditable _inability to assert_ — never to a hard stop on delivery, and
|
||||
never to a silent pass. Correctness failures (unknown, malformed, terminal-failure) are what must
|
||||
block.
|
||||
|
||||
This is also the **Pi-brick shape** (P-RECOVERY-001): a gate whose own unavailability prevents the
|
||||
work needed to recover from it.
|
||||
|
||||
**Requirement on RM-03, extending its existing two defects:** the guard must distinguish
|
||||
`CANNOT_ASSERT` (credential/transport/provider unavailable — loud, audited, does not silently pass and
|
||||
does not permanently block) from `ASSERTED_NOT_READY` (a real non-green CI state — blocks). Both are
|
||||
registered R-002 cases with must-fail controls; neither may exit 0 silently.
|
||||
|
||||
### D-9 — the comms path shell-interprets message bodies (injection-shaped, found by accident)
|
||||
|
||||
Sending a status message with `agent-send.sh -m "...`backticks`..."` caused bash to **execute** the
|
||||
backticked text as command substitution. The recipient received a mangled body plus a
|
||||
`No such file or directory` error; the intended sentence never arrived. The message was reported as
|
||||
delivered.
|
||||
|
||||
This is the **same class** as the already-noted `pr-create.sh` backtick-quoting bug (M2 scratchpad):
|
||||
**two tools in the comms path treat a message body as shell input.** A body that can execute on the
|
||||
sender is a _correctness_ bug before it is ever a security one — and note the failure mode: the
|
||||
send reported success while silently transmitting something other than what was written. Silent
|
||||
corruption with a success receipt is precisely the pattern this mission exists to eliminate.
|
||||
|
||||
**Requirement on RM-40 / RM-42 (comms/v1), hardened by Mos.** The envelope must carry its payload
|
||||
**verbatim** and must not be subject to shell interpretation at **any** hop — sender, transport, or
|
||||
adapter. Concretely: **file/stdin transport, never argv interpolation.**
|
||||
|
||||
**Standing interim rule, effective now (Mos).** Until the envelope lands, use `agent-send.sh -f
|
||||
<file>` for any message body containing special characters — **never `-m`**. Passing a file sidesteps
|
||||
argv interpolation entirely. **This rule is mandatory in every worker brief this mission issues**,
|
||||
alongside the D-8 "if a check is unrunnable, say so" clause. Round-trip fidelity (send a body containing backticks, `$(…)`, quotes, and newlines; assert
|
||||
byte-identical receipt) is a required registered test case under RM-02, including a must-fail control
|
||||
proving the assertion can detect corruption.
|
||||
|
||||
### D-8 — a PRE-REGISTERED acceptance check that was not runnable as written
|
||||
|
||||
On PR #1025 the author (me) pre-registered AC2 with the fixture snippet `mkdir -p apps/*/venv/lib`.
|
||||
In bash, when no `venv` exists the glob is unmatched and passes through literally, creating a
|
||||
directory named `apps/*/venv/lib` rather than one per workspace. The check as written did not test
|
||||
what it claimed to test.
|
||||
|
||||
`rev-974` ran it **exactly as written**, observed the wrong behaviour, then re-ran the intended
|
||||
assertion at an explicit path — **and said so in the review** rather than silently substituting a
|
||||
working fixture and reporting PASS.
|
||||
|
||||
Two things this establishes:
|
||||
|
||||
1. **The instruction "do not adjust a check to fit the diff; if it is unrunnable, say so explicitly"
|
||||
worked.** A silent substitution here would have produced a green AC2 that proved nothing, on the
|
||||
exact task whose subject is gates that appear to work. The disclosure is what made the PASS
|
||||
meaningful.
|
||||
2. **Pre-registration does not confer correctness.** A pre-registered check is protected from being
|
||||
retrofitted to the implementation; it is not protected from being _wrong when written_. This is a
|
||||
small instance of the mission's own class — an unverified gate — occurring inside the mechanism
|
||||
built to catch unverified gates.
|
||||
|
||||
**Requirement on RM-02 (non-negotiable, sharpened by Mos).** The registry must **self-verify** that
|
||||
every registered case demonstrably **runs** and demonstrably **fails on a known-bad input**.
|
||||
Presence in the registry is **not** evidence. **A check is not trusted until it has been shown to
|
||||
fail.** This is mutation testing / negative control applied _at the registry level_ — meaning
|
||||
**the conformance harness must itself be conformance-tested.** A registered case that cannot fail, or
|
||||
cannot run, is exactly as inert as an unregistered one, and the registry check must detect that
|
||||
itself rather than assume it.
|
||||
|
||||
**Requirement on RM-55.** The same recursion applies to the harness: it must be observed red before
|
||||
its green is worth anything (OPUS R-063 AC1 already states this; D-8 is the empirical case for it).
|
||||
|
||||
**Second, equally load-bearing lesson — reviewer disclosure is what makes a review trustworthy.**
|
||||
rev-974 could have silently swapped in a working fixture and reported `AC2 PASS`. Nothing in the
|
||||
process would have caught it, and the resulting green would have certified nothing — on the very task
|
||||
whose subject is gates that only appear to work. The brief's instruction — _"do not adjust a check to
|
||||
fit the diff; if it is genuinely unrunnable as specified, say so explicitly and explain why rather
|
||||
than silently substituting your own"_ — is therefore not boilerplate. It is the clause that makes a
|
||||
PASS mean something, and it must appear in **every** reviewer brief this mission issues.
|
||||
|
||||
### D-7 — shared-tmpfs contention → cascading ENOSPC (live incident, 2026-07-31)
|
||||
|
||||
The shared 30 G `/tmp` hit **100% ENOSPC** mid-session. It broke tool calls in **two different seats**
|
||||
(mine and Mos's) — a single full disk degrades every agent on the host at once. Recurring: prior
|
||||
incidents 2026-06-18 and 2026-07-17.
|
||||
|
||||
Attribution matters, because the wrong owner cleans the wrong thing. Measured:
|
||||
|
||||
| path | size | last modified | owner |
|
||||
| ---------------------------------------------- | --------- | ---------------------------- | ------------------------------------------------- |
|
||||
| `…/-src-mosaic-stack/6d2faee6…` (this session) | **88 K** | live | mos-remediation |
|
||||
| `…/-src-mosaic-stack/c743185d…` | **3.6 G** | **2026-07-22** (9 days dead) | abandoned session, same project path |
|
||||
| `…/claude-1001/pnpm-store` | **1.6 G** | **2026-07-23** (8 days dead) | abandoned; the live store is correctly on `$HOME` |
|
||||
|
||||
So ~5.2 G — the bulk of the pressure — is **dead session scratch that nothing will ever read again**.
|
||||
This is not a quota problem; it is **P-FLEET-001's stale-session GC, applied to disk instead of tmux
|
||||
sessions.** The same missing capability (nothing owns reaping dead ephemeral state) produces both the
|
||||
orphaned-session failure and this one. Reaping dead-session scratch belongs in RM-50 alongside stale
|
||||
tmux-session GC.
|
||||
|
||||
**Added to RM-01 as acceptance criteria:** heavy build artifacts (node_modules, package stores, build
|
||||
output) must land on the main disk in the worktree, never on the shared 30 G `/tmp`.
|
||||
|
||||
**Resolution, and the part that is actually the finding.** Mos verified the attribution independently
|
||||
(mtimes, no process or `lsof` holding either path, no live session maps) and reaped both as lead
|
||||
coordinator: `/tmp` went to 79%, 6.0 G free. But note _how_ it was resolved — **a human-authority seat
|
||||
did it by hand, because the authority exists and the reaper does not.** That gap is the finding, not
|
||||
the disk usage.
|
||||
|
||||
Two doctrine points fall out, both binding on RM-50:
|
||||
|
||||
1. **The fix is not "agents should tidy up."** Asking each seat to clean its own scratch is
|
||||
`instructions are not enforcement` (D-4) wearing a different hat. A deterministic reaper must own
|
||||
it — same conclusion the north star reaches for every other class in this mission.
|
||||
2. **Refusing to unilaterally delete another session's scratch was correct, and the resolution is not
|
||||
"be braver about deleting."** An agent guessing that someone else's state is garbage is exactly the
|
||||
unreviewed destructive act the Constitution forbids. The resolution is that _ownership and liveness
|
||||
become mechanically decidable_, so reaping is a determination rather than a judgement call.
|
||||
|
||||
**Reaper requirements for RM-50:** liveness determined mechanically (process/`lsof`/session-map, not
|
||||
mtime alone); an age threshold; a dry-run that reports what it would reap and why; and an audit event
|
||||
per reap. Never a heuristic sweep — that would reintroduce the P-WORKFLOW-001 auto-sync failure in a
|
||||
more destructive form.
|
||||
|
||||
**The self-erasure is the important part.** An inert gate that is masked by unrelated downstream
|
||||
commits produces no lasting artifact, which is precisely why this class survives for months. Detection
|
||||
cannot rely on "is `main` currently red" — it must be per-merge-commit.
|
||||
|
||||
This matters more than the one-line fix:
|
||||
|
||||
- It is the **P-QUEUE-001 / P-CONFORMANCE-001 class** ("gate-6 was inert fleet-wide"), reproduced in
|
||||
the repository this mission is remediating, discovered incidentally.
|
||||
- It independently **validates OPUS premise A1** ("every gate is inert until proven otherwise") with
|
||||
live evidence rather than argument — which is why RM-02 is adopted as the keystone (§2, X2).
|
||||
- The file fix rides in its own hygiene PR. **The inert gate itself is NOT quiet-patched.** Per Mos:
|
||||
it stays a first-class backlog item, because patching the symptom would destroy the signal.
|
||||
|
||||
**Binding requirement on RM-02 and RM-55:** the gate registry and the conformance harness must assert
|
||||
**"every merged commit passed every required gate"** — evaluated **per merge commit, against that
|
||||
commit's own tree**, not against current `main`. As the table above proves, a "is main green today"
|
||||
check would have reported all-clear. A merged-commit-that-fails-a-required-gate is the exact detection
|
||||
signal, and it must be a registered must-fail case. A gate that cannot prove it blocked something has
|
||||
not been shown to work.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where they genuinely disagree (not averaged — adjudicated)
|
||||
|
||||
| # | Axis | OPUS | SOL | My ruling |
|
||||
| --- | ------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| X1 | **Total cost** | 38 tasks, ~5.3M tok | 25 tasks, ~294K tok | **~18× apart.** Not reconcilable by splitting. They measure different things: SOL explicitly excludes orchestration/review/iteration overhead and assumes one remediation pass; OPUS prices the full loop. Adopt **SOL's scope** with **OPUS's rigor**, and treat SOL's G1 as a hard budget checkpoint (§4). Re-estimate empirically after the first three merged PRs rather than trusting either number. |
|
||||
| X2 | **Gate registry (OPUS R-002)** | Keystone; blocks all P2 | Absent; only a queue-guard fix | **ADOPT OPUS.** Empirically validated in this very session: I found `pnpm format:check` red on `main` via merged PR #868 — a required gate that did not block. OPUS's premise A1 ("every gate is inert until proven otherwise") is not theoretical; it reproduced today, unprompted. Scope it tighter than 120K. |
|
||||
| X3 | **Drizzle PG first-install defect (R-010)** | Hidden blocker; everything downstream depends on it | Not mentioned | **ADOPT OPUS.** `packages/db/src/migrate.ts:30-38` carries a TODO admitting postgres-tier first-install fails today. The spine has only ever been proven on PGlite. Every later migration silently depends on this. SOL missed it. |
|
||||
| X4 | **Rollback artifact for the hard cutover** | D3: hard cutover needs a rehearsed rollback snapshot | SOL-07: import-only, explicitly no dual-write | Both obey "no flat-file interim." OPUS wants a one-directional snapshot nothing reads as authority. I read that as compatible with the directive, but it is Jason's call → **DECISION-2** (§5). |
|
||||
| X5 | **Where the queue guard sits** | P0, independent of spine | SOL-02, also early | Agree it is P0. But ownership collides with **parked PR #1023** → **DECISION-3** (§5). |
|
||||
| X6 | **Report-only rollout** | D5: only with a hard expiry, else withdraw | not raised | **ADOPT OPUS.** A report-only gate is by definition inert; expiry is the mechanism that stops it becoming the new fail-open. |
|
||||
| X7 | **Availability trade (FC-7/FC-11)** | D8: "no DB ⇒ fleet stops" must be pre-committed in writing | not raised | Genuine availability regression, correctly identified. Needs Jason → folded into **DECISION-2**. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Reconciled DAG
|
||||
|
||||
Phases run in order; `⛔` marks a hard barrier. `src` shows lineage (`O`=opus, `S`=sol, `O+S`=both).
|
||||
Estimates are given as a **range** (SOL low / OPUS high) rather than a fabricated midpoint — the
|
||||
spread is itself information, and X1 says we calibrate on real merged PRs.
|
||||
|
||||
### P0 — Make gates provable, and stop the fleet re-bricking
|
||||
|
||||
⛔ _No gate-introducing task in any later phase may merge before RM-02._
|
||||
|
||||
| id | task | src | depends_on | est (S/O) | tier |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------ | ---------------- | ------ |
|
||||
| RM-01 | Reproducible non-root checkout; gate fails on **code, not env**; heavy artifacts OFF shared `/tmp` (banks D-1/D-2/D-5/D-7) | O+S+live | — | 6K / 60K | codex |
|
||||
| RM-02 | **Gate registry + negative-control CI check** (anti-inert-gate harness) ★keystone | O | RM-01 | — / 120K | opus |
|
||||
| RM-03 ⏸**HOLD** | Queue-guard: **two** defects — (a) `unknown`/`no-status`/malformed ⇒ ≠0, (b) guard evaluates `branch=main` instead of the branch being pushed | O+S+live | RM-02 | 8K / 100K | sonnet |
|
||||
| RM-04 | Activation/version coherence; block launch on skew, fail SAFE; honest `doctor` labels | O+S | RM-01 | (in S-01) / 140K | sonnet |
|
||||
| RM-05 | Break-glass replaces the three silent `MOSAIC BYPASS` fail-opens | O | RM-04, RM-02 | — / 120K | opus |
|
||||
|
||||
> ⚠ **RM-05 must not merge before RM-04.** The bypasses exist because the lease-broker daemon was
|
||||
> never _deployed_ on this host — removing the fail-open before deployment coherence is real
|
||||
> re-creates the 2026-07-22 bricking incident. Hard edge, from OPUS.
|
||||
|
||||
### P1 — Durable spine (PG)
|
||||
|
||||
⛔ _No migration may merge before RM-10._
|
||||
|
||||
| id | task | src | depends_on | est (S/O) | tier |
|
||||
| ----- | --------------------------------------------------------------------------------------------- | --- | ---------- | ---------- | ------ |
|
||||
| RM-10 | **Fix the Drizzle postgres-tier first-install defect** ★hidden blocker | O | RM-01 | — / 90K | sonnet |
|
||||
| RM-11 | Orchestration spine schema (tasks, attempts, gate_results, hash-chained ledger, typed claims) | O+S | RM-10 | 12K / 160K | opus |
|
||||
| RM-12 | Spine client, fail-closed connection (no silent PGlite in prod) | O | RM-11 | — / 80K | sonnet |
|
||||
| RM-13 | Atomic claims/transitions + transactional outbox + reconciliation sweeper | O+S | RM-12 | 12K / 140K | opus |
|
||||
|
||||
### P2 — The single choke point
|
||||
|
||||
⛔ _RM-25 (no-second-path) lands in the same milestone as RM-20, or the choke point is optional._
|
||||
|
||||
| id | task | src | depends_on | est (S/O) | tier |
|
||||
| ----- | ---------------------------------------------------------------------------------------------- | --- | ------------------- | ---------------- | ------ |
|
||||
| RM-20 | Canonical MACP contract completion (Task/Result/Event/Claim/tri-state outcome) | S | — | 8K / (in R-020) | codex |
|
||||
| RM-21 | **Production `TaskExecutor`** backed by `@mosaicstack/macp` ★keystone | O+S | RM-12, RM-02, RM-20 | 16K / 220K | opus |
|
||||
| RM-22 | Gate-runner hardening: `fail_on`, timeouts, **empty gate set = failure** | O | RM-21 | — / 120K | sonnet |
|
||||
| RM-23 | Hash-chained MACPEvent ledger in PG + lifecycle EventType extension | O+S | RM-21, RM-11 | — / 160K | opus |
|
||||
| RM-24 | Seat identity from `MOSAIC_AGENT_NAME` + **mandatory** tri-state write outcomes | O+S | RM-21 | (in S-03) / 150K | opus |
|
||||
| RM-25 | **No-second-path gate:** terminal status writable only by the executor | O | RM-21, RM-23 | — / 140K | opus |
|
||||
| RM-26 | `packages/coord` submits through the executor (retire direct spawn) | O+S | RM-21 | 16K / 140K | sonnet |
|
||||
| RM-27 | `mosaic yolo/claude/codex/pi` launch path records typed Task + events | O | RM-21, RM-23 | — / 160K | sonnet |
|
||||
| RM-28 | Delete the Forge stub executor (empty-gate-list "success"); Forge submits through the real one | O+S | RM-21 | 10K / 90K | codex |
|
||||
| RM-29 | One-shot flat-file import + cutover readiness audit (dry-run, idempotent, no dual-write) | S | RM-13 | 8K / (in R-062) | codex |
|
||||
|
||||
> **★ G1 — FIRST DOGFOOD. Stop here and prove it.** One live fleet task travels
|
||||
> PG claim → TaskExecutor → worker → gates → terminal PG result/event, with **no** flat-file state.
|
||||
> Adopted from SOL wholesale. If G1 cannot carry a real task, **do not build Redis, rotation, comms,
|
||||
> or conformance** — remediate instead. This is the budget escape hatch (§4).
|
||||
|
||||
### P3 — Rotation lifecycle (finish the Mission Control Plane)
|
||||
|
||||
| id | task | src | depends_on | est (S/O) | tier |
|
||||
| ----- | -------------------------------------------------------------------------------------------- | --- | ------------ | ---------------- | ------ |
|
||||
| RM-30 | Typed state claims (source/confidence/TTL) with HMAC integrity, fail-closed | O+S | RM-11, RM-21 | (in S-03) / 170K | opus |
|
||||
| RM-31 | Contract-hash binding; stale generation loses mutation authority **mechanically** | O+S | RM-21, RM-30 | 12K / 180K | opus |
|
||||
| RM-32 | Durable compaction/token sensor (per-runtime thresholds, PreCompact event) | O | RM-23, RM-31 | — / 130K | sonnet |
|
||||
| RM-33 | Typed checkpoint writer (structured claims, never transcript) + digest | O+S | RM-30, RM-32 | 12K / 150K | opus |
|
||||
| RM-34 | **Rotation daemon:** watch → checkpoint → revoke → kill → relaunch → rehydrate | O+S | RM-33, RM-26 | 16K / 240K | opus |
|
||||
| RM-35 | Rehydration attestation gate: refuse to act on an incomplete claim set | O | RM-33 | — / 130K | opus |
|
||||
| RM-36 | Broker-independent recovery; remove silent bypass; honest capability labels | S | RM-34 | 12K / (in R-004) | sonnet |
|
||||
| RM-37 | Delete `/compact and continue` from the persistent-seat path (**substitution**, not removal) | O+S | RM-34, RM-44 | (in S-16) / 60K | codex |
|
||||
|
||||
### P4 — Comms service
|
||||
|
||||
⛔ _RM-50 (one roster-owned socket per host) precedes identity-addressed delivery._
|
||||
|
||||
| id | task | src | depends_on | est (S/O) | tier |
|
||||
| ----- | ---------------------------------------------------------------------------- | --- | ------------ | ---------------- | ------ |
|
||||
| RM-40 | `comms/v1` envelope + protocol-version negotiation, LOUD reject | O+S | RM-11, RM-31 | 8K / 140K | opus |
|
||||
| RM-41 | Comms service: PG state machine PENDING→RECEIVED→CONSUMED→DEAD-LETTER | O+S | RM-40, RM-13 | 16K / 200K | opus |
|
||||
| RM-42 | tmux transport as a **dumb adapter**; durable retry before cursor advance | O+S | RM-41, RM-50 | (in S-19) / 160K | sonnet |
|
||||
| RM-43 | Per-class coalescing + supersede (the stale-consumed-as-live fix) | O+S | RM-41 | 12K / 130K | sonnet |
|
||||
| RM-44 | Redis Streams hot delivery + provenance guard (**Redis is never authority**) | O+S | RM-41, RM-13 | 12K / 170K | opus |
|
||||
| RM-45 | Retire direct tmux sends; only the service may write a pane | O+S | RM-42, RM-43 | (in S-20) / 100K | codex |
|
||||
|
||||
### P5 — Retirements, hygiene, conformance
|
||||
|
||||
| id | task | src | depends_on | est (S/O) | tier |
|
||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------- | ---------------- | ------ |
|
||||
| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet |
|
||||
| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet |
|
||||
| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex |
|
||||
| RM-53 | Flat-file orchestration → DB hard cutover, with rehearsed rollback artifact | O+S | RM-27, RM-30, RM-34, RM-29 | (in S-10) / 200K | opus |
|
||||
| RM-54 | Fleet-wide inert-gate audit against the RM-02 registry | O | RM-02 | — / 120K | sonnet |
|
||||
| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus |
|
||||
| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex |
|
||||
| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex |
|
||||
| RM-59 | **Close the D-19 residual risk** — generated-state verification anchored **outside** the worktree's authority (executor/spine-side attestation), retiring the same-UID self-authentication gap | mos-remediation (D-19) | RM-12, RM-21, RM-25 | 20K | opus |
|
||||
| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet |
|
||||
|
||||
**Critical path:** `RM-01 → RM-02 → RM-10 → RM-11 → RM-12 → RM-21 → RM-23 → RM-31 → RM-33 → RM-34 → RM-53 → RM-55`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Execution discipline
|
||||
|
||||
- **Every row is one PR.** Author ≠ reviewer; `rev-974` is the mosaicstack reviewer identity.
|
||||
- **Pre-registered, diff-blind acceptance checks are committed BEFORE the reviewer reads the diff.**
|
||||
Both decomps wrote their ACs in runnable `⇒0` / `⇒≠0` form specifically to make this possible.
|
||||
- **Every gate-introducing task carries at least one registered must-fail negative control.** This is
|
||||
RM-02's whole purpose; a gate with no proven failure path manufactures evidence.
|
||||
- **Cost tiers:** codex for mechanical/unambiguous, sonnet for normal feature work, opus reserved for
|
||||
security/integrity/cross-cutting-invariant tasks. SOL priced 0 opus tokens; OPUS priced 14 opus
|
||||
tasks. I am keeping opus only where the failure is _integrity_, not merely complexity.
|
||||
- **G1 is the budget checkpoint.** If the first-dogfood slice overruns SOL's estimate by >3×, stop and
|
||||
re-plan rather than spending the remainder. X1 says neither estimate is trustworthy until calibrated.
|
||||
- **Defer list adopted from SOL** (10 items): mission dashboard/TUI, PRD-to-board auto-decomposition,
|
||||
heuristic churn scoring, Discord/Slack/Telegram adapters, public MCP comms surface, protocol-v2
|
||||
negotiation, multi-region PG/Redis, event analytics UI.
|
||||
|
||||
---
|
||||
|
||||
## 5. Decisions — all three ruled by Mos, 2026-07-31
|
||||
|
||||
**DECISION-1 — the wire-in point. ✅ RULED: accept the planners (Mos, 2026-07-31).**
|
||||
The charter's `mosaic_orchestrator.py::run_single_task` target is the **disabled Python controller
|
||||
this mission retires**; wiring the new choke point into the rail we are deleting is wrong.
|
||||
|
||||
> **Corrected target (authoritative):** a **new production Node `TaskExecutor`** sitting on the
|
||||
> **live dispatch path** — `packages/mosaic` launch + `packages/coord` — which Coord, Forge, and live
|
||||
> dispatch all **submit through**. This is the MACP scout's _full_ recommendation ("replace the block
|
||||
> **with** a Node executor **and** make Coord/Forge submit through it"), not a resurrection of the
|
||||
> Python controller. RM-52 is therefore a **deletion** task, and Build 1's acceptance is measured on a
|
||||
> live `mosaic yolo` invocation.
|
||||
|
||||
Mos ruled this resolvable from the already-accepted retire-the-Python-rail decision — his authority,
|
||||
not a Jason escalation. RM-21/RM-26/RM-27/RM-52 all take the corrected target.
|
||||
|
||||
**DECISION-2 — rollback artifact + availability trade. ⏸ JASON-PENDING — NOT BLOCKING.**
|
||||
The DB build is phases away, so this is queued for Jason's next session rather than escalated now.
|
||||
**Binding requirement in the meantime (Mos, from P-RECOVERY-001):** the DB spine **must NOT be a
|
||||
single-point hard-stop.** Design for a broker-independent / degraded mode **plus** a rollback
|
||||
artifact. Jason finalises only the specific availability target. This reverses my earlier reading of
|
||||
OPUS D8 ("the fallback is: the fleet stops") — that answer is **not** pre-committed; a degraded mode
|
||||
is now a design requirement on RM-12, RM-13, RM-23, RM-36 and RM-53.
|
||||
|
||||
**DECISION-3 — RM-03 vs. parked PR #1023. ✅ RULED: HOLD RM-03 (Mos, 2026-07-31).**
|
||||
Do **not** open a third gate-6 lane — that is the postmortem's own anti-pattern performed by the
|
||||
remediation. PR #1023 sits in Jason's **parked delivery stack**; its disposition (close, or supersede
|
||||
by RM-03) is Jason's at his next session.
|
||||
|
||||
- **PR #1023 → `SUPERSEDED-PENDING-JASON`.** RM-03 stays `HOLD`; when Jason rules, RM-03 proceeds as
|
||||
the single correct lane.
|
||||
- **RM-02 and RM-55 proceed independently and are NOT held.** The per-merge-commit gate-assertion
|
||||
requirement is the _conformance_ capability, not the gate-6 fix itself — different scope, no
|
||||
ownership collision.
|
||||
|
||||
---
|
||||
|
||||
## 6. Status
|
||||
|
||||
| phase | state |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Decomposition | DONE — both planners delivered independently |
|
||||
| Reconciliation | DONE — this document |
|
||||
| Blocking decisions | **RULED** — all 3 closed by Mos 2026-07-31 (§5); D-2's availability target is Jason-pending but non-blocking |
|
||||
| Dispatch | **RM-01 IN FLIGHT** — f10-coder (codex), worktree-isolated, AC1–AC8 pre-registered |
|
||||
| Review | PR #1025 with rev-974; ACs pre-registered 22:12:26Z before diff exposure |
|
||||
@@ -1,118 +0,0 @@
|
||||
# RM-61 — CI contract exemption for #1000 teardown artifact
|
||||
|
||||
**Tracking:** RM-61 / issue #1000
|
||||
|
||||
**Branch:** `fix/rm-61-ci-contract-exemption`
|
||||
**Owner:** `coder-mos1`
|
||||
|
||||
## Objective
|
||||
|
||||
Determine, by red-first provider controls, whether the `ci-postgres` pod-not-found teardown signature discriminates from a real PostgreSQL failure. Only if it discriminates may a named, bounded CI-contract exemption be implemented. The exemption must retire when #1000 is fixed; fixing #1000 is the closure path.
|
||||
|
||||
## Pre-registered kill criterion
|
||||
|
||||
If an injected real `ci-postgres` failure also yields `pods "wp-svc-<ULID>-ci-postgres" not found` as the service's provider-visible failure, the signature does not discriminate. Option B is unsafe; stop exemption implementation and fall to Option A (#1000).
|
||||
|
||||
## Plan
|
||||
|
||||
1. Capture full `-f json` records for the 11 supplied observations and state counts.
|
||||
2. Run one startup-failure control using the real pgvector/PostgreSQL image with an invalid `initdb` argument.
|
||||
3. Run one post-readiness crash control using real PostgreSQL, `pg_isready`, and a deliberate postmaster kill while a DB-dependent probe is active.
|
||||
4. Compare the raw `ci-postgres` service record independently of failures in dependent steps.
|
||||
5. Investigate runner/time/head clustering only as a hypothesis; never encode incidental correlates or retries into policy.
|
||||
6. If and only if the controls discriminate, implement and test the exact exemption, document its two-way boundary, and track retirement at #1000.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap supplied. Working estimate: 20K–30K tokens. Limit provider controls to the two pre-registered runs; no retries or re-roll policy.
|
||||
|
||||
## Initial evidence
|
||||
|
||||
Historical JSON saved locally under `.evidence/rm-61/` (not for commit). Supplied pipelines: 11 total. Child-step counts: five pipelines with 9 children and six with 10 children. Seven contain the `ci-postgres` pod-not-found failure (#2170, #2175, #2180, #2181, #2182, #2187, #2188); four do not (#2158, #2167, #2184, #2186). Every observed workflow reports `agent_id=44`, so the available JSON does not separate clean and artifact runs by runner. This refutes runner identity as a discriminator in the sampled record.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Requirements and kill criterion recorded before control implementation.
|
||||
- [x] Historical full-JSON records captured.
|
||||
- [x] Startup-failure control observed terminal.
|
||||
- [x] Post-readiness crash control observed terminal.
|
||||
- [x] Discrimination verdict recorded: Option B may proceed.
|
||||
- [x] Conditional exemption implementation.
|
||||
|
||||
## Tests / evidence
|
||||
|
||||
### Control 1 — real startup failure
|
||||
|
||||
- Commit: `3931b0e29eb834914f7b17e4db7e221481d436fa`
|
||||
- Pipeline: #2189, exact commit match.
|
||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||
- `ci-postgres`: `state=failure`, `exit_code=1`, `error=null`, with a five-second execution window.
|
||||
- `test`: `state=failure`, `exit_code=1` after the readiness budget expired.
|
||||
- Pipeline/workflow: terminal `failure`.
|
||||
|
||||
This control is red and its service record differs from #1000 (`exit_code=0` plus pod-not-found). It proves the startup-failure direction only. It does not settle the dangerous post-readiness crash/garbage-collection path.
|
||||
|
||||
### Control 2 — real post-readiness crash
|
||||
|
||||
- Commit: `25ac59715a94dd1b52ef42577472eb44ecc4b446`
|
||||
- Pipeline: #2191, exact commit match.
|
||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||
- Service log proves PostgreSQL reached `database system is ready to accept connections`, the test created the arm table, and the service then killed postmaster PID 7.
|
||||
- Test log proves a successful `SELECT 1` followed by `Connection refused`; it exited the pre-registered control code 61.
|
||||
- `ci-postgres`: `state=failure`, `exit_code=137`, `error=null`, with a 203-second execution window.
|
||||
- `test`: `state=failure`, `exit_code=61`.
|
||||
- Pipeline/workflow: terminal `failure`.
|
||||
|
||||
This is the dangerous post-readiness crash path. Its service record is not pod-not-found and therefore differs from #1000 independently of the dependent test failure.
|
||||
|
||||
### Discrimination verdict
|
||||
|
||||
Both real failures are provider-visible as process exits (`exit_code=1` startup; `exit_code=137` crash) with no pod-not-found error. The seven observed #1000 artifacts are provider reconciliation misses (`exit_code=0` plus the exact pod-not-found error). The declared kill criterion did not fire, so Option B may proceed with a matcher requiring the full conjunction. This evidence does **not** prove every future Kubernetes failure is distinguishable; it proves these two concrete real-failure classes remain blocking and bounds the exemption to the observed reconciliation shape.
|
||||
|
||||
### Unit red-first checkpoint
|
||||
|
||||
The nine-case contract harness was written before the verifier. First execution exited 1 because `verify-terminal-green.py` did not exist; no exemption implementation was live. Cases pre-register ordinary green, the exact artifact, both provider controls, near-miss signatures, an independent failure, and a skipped step.
|
||||
|
||||
### Control 2 setup attempt — invalid, excluded from evidence
|
||||
|
||||
- Commit: `9455cd6a2650b2b7e70f746c07933d96e5cb3d20`
|
||||
- Pipeline: #2190, exact commit match.
|
||||
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
|
||||
- Service log: `/bin/sh: 0: -c requires an argument`.
|
||||
- Root cause: Woodpecker service `commands` did not become the third `sh -c` argument. PostgreSQL never started, so this run is **not** the post-readiness crash control and provides no discrimination evidence.
|
||||
- Focused remediation: place the script directly in the third `entrypoint` element and supply `PGPASSWORD` for the marker query. This is a control-fixture correction, not a retry of #1000 and not evidence for either verdict.
|
||||
|
||||
## Implementation evidence
|
||||
|
||||
- `verify-terminal-green.py` consumes only the full JSON/API record; it performs no fetch, retry, or trigger.
|
||||
- Exact #2188 record: exit 0, 10 children, 9 success + 1 named exemption.
|
||||
- Historical set: #2158/#2167/#2184/#2186 pass with no exemption; #2170/#2175/#2182/#2187/#2188 pass with one named exemption; #2180/#2181 remain red because independent failures exist.
|
||||
- Provider controls: #2189 and #2191 both exit 1 under the verifier; neither is exempted.
|
||||
- Unit harness: initial 9/9 cases passed after the red-first checkpoint; review remediation expands this to 12 cases with expected-head match/missing/mismatch coverage.
|
||||
- Test-membership guard: PASS, population 45; 26 enumerated, 19 signed exclusions; all 39 surface paths present.
|
||||
- Python compile: PASS.
|
||||
- `pnpm typecheck`: PASS, 45/45 tasks.
|
||||
- `pnpm lint`: PASS, 25/25 tasks.
|
||||
- `pnpm format:check`: PASS after moving local evidence outside the repository tree.
|
||||
- `test:framework-shell`: RM-61 and all preceding suites passed, then the pre-existing wake assertion aborted with exit 97 because this host's Bash 5.2.15 reports `BASH_LINENO [3 5]` where that suite requires `[3 4]`. RM-61 does not modify the wake suite; the command is not fully runnable on this host as written and no substitute result is claimed.
|
||||
|
||||
## Independent review
|
||||
|
||||
- Review 67 / comment 20403 at exact head `e7b29219e11efd0a19395156ac0b154bec0c3a73`: **REQUEST CHANGES**.
|
||||
- Blocker: the verifier echoed the pipeline commit but did not bind it to the current PR head; mutating only #2188's commit still returned terminal-green.
|
||||
- Remediation: require `--expect-commit <full-40>`, add a pipeline anomaly on missing/mismatched record commits, emit expected and observed values, wire both CI documentation and the merge-gate baseline to pass provider PR head, and add match/missing/mismatch tests.
|
||||
- This binding is not prohibited head-based clustering policy: it proves the evidence belongs to the commit under verdict. Runner/node/time/head correlation remains excluded from the teardown signature itself.
|
||||
- Review 69 later approved the commit-binding remediation at exact head `033b2ffb46674b2c0bcc5197273c109b461f62d9`; pipeline #2193 was 9/9 success. Before merge-gate, an independent adjudicator found that Python treats JSON `false == 0`, allowing a non-integer exit value to match. The prior gate-ready state was withdrawn. The type-strict set distinguishes genuine red-first controls (`false`, `0.0`, which wrongly exempted) from regression guards (`true`, `"0"`, `null`, which already blocked). Remediation requires the decoded type to be exactly `int` and excludes `bool` explicitly.
|
||||
|
||||
## Documentation checklist
|
||||
|
||||
- [x] CI contract documented in the canonical framework CI/CD guide.
|
||||
- [x] Operator command documented in the Woodpecker tool README.
|
||||
- [x] Merge-gate baseline points to the deterministic verifier and named retirement.
|
||||
- [x] Tracking and retirement cite issue #1000.
|
||||
- [x] Both positive and negative guarantee boundaries are stated.
|
||||
- [x] No API/auth/schema/user-facing navigation change; OpenAPI, user guide, and sitemap are not applicable.
|
||||
|
||||
## Risks
|
||||
|
||||
The controls establish discrimination for deterministic startup failure and an armed post-readiness postmaster crash on the current Woodpecker Kubernetes provider. They cannot prove that every future Kubernetes failure mode will preserve a non-zero exit before reconciliation. The exact matcher minimizes that residual risk, and issue #1000 remains the mandatory provider-seam closure and retirement trigger.
|
||||
@@ -1,82 +0,0 @@
|
||||
# MC-CRED-01 / stack #1045 scratchpad
|
||||
|
||||
Last updated: 2026-08-05
|
||||
|
||||
## 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.
|
||||
- [ ] 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.
|
||||
|
||||
Current focused evidence: 77/77 across 11 credential/command suites; package lint, typecheck, formatting, and build are green. Full package Vitest reached 1,578 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 atomic; 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.
|
||||
|
||||
## Risks/blockers
|
||||
|
||||
- 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 must first be rebuilt from only its four commits on `main`; the retargeted head `8b067839` carries 13 unrelated `next` commits and is not merge-eligible. MC-CRED remains sequenced after the clean C1 merge.
|
||||
@@ -1,58 +0,0 @@
|
||||
# RM-01 — Reproducible checkout
|
||||
|
||||
- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking)
|
||||
- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats.
|
||||
- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation.
|
||||
- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge.
|
||||
- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum.
|
||||
- Plan:
|
||||
1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation.
|
||||
2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation.
|
||||
3. Run focused tests, install/build/baseline gates, and explicit AC negative controls.
|
||||
4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`.
|
||||
- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup.
|
||||
- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification.
|
||||
|
||||
## Progress / evidence
|
||||
|
||||
- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`.
|
||||
- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`.
|
||||
- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness.
|
||||
|
||||
## Checkpoint evidence (c45e5e19)
|
||||
|
||||
- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed.
|
||||
- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0.
|
||||
- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0.
|
||||
- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed.
|
||||
- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted.
|
||||
- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint.
|
||||
- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially.
|
||||
- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree.
|
||||
|
||||
## Continuation evidence
|
||||
|
||||
- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression.
|
||||
- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs.
|
||||
- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result.
|
||||
- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974.
|
||||
- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence.
|
||||
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
|
||||
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
|
||||
|
||||
## Review remediation — restated AC2
|
||||
|
||||
- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation.
|
||||
- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest.
|
||||
- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted.
|
||||
- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0.
|
||||
- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25.
|
||||
- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence.
|
||||
|
||||
## Handoff
|
||||
|
||||
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
|
||||
2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes.
|
||||
3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config.
|
||||
4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree.
|
||||
5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate.
|
||||
@@ -1,120 +0,0 @@
|
||||
# RM-03 — CI Queue Guard Repair
|
||||
|
||||
- **Task:** RM-03
|
||||
- **Issue:** #1019
|
||||
- **Branch:** `fix/rm-03-queue-guard`
|
||||
- **Owner:** coder-mos1
|
||||
- **Reviewer:** rev-974 (independent; author != reviewer)
|
||||
- **Started:** 2026-08-01
|
||||
|
||||
## Objective
|
||||
|
||||
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
|
||||
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
|
||||
- TDD is mandatory. Every behavior case must be observed red before implementation.
|
||||
- No bypass flags or hook suppression.
|
||||
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
|
||||
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
|
||||
- No merge: coordinator holds the merge hand pending Jason.
|
||||
|
||||
## Design
|
||||
|
||||
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
|
||||
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
|
||||
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
|
||||
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
|
||||
|
||||
## Test matrix
|
||||
|
||||
| Case | Required outcome |
|
||||
| --- | --- |
|
||||
| success | exit 0; terminal-success |
|
||||
| pending | nonzero after bounded timeout |
|
||||
| failure | nonzero |
|
||||
| no-status | nonzero |
|
||||
| malformed | nonzero |
|
||||
| >=150 KiB payload | unchanged classification; never rc126 |
|
||||
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
|
||||
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
|
||||
| audit unavailable | nonzero |
|
||||
| implicit push branch | provider URL uses checked-out feature branch |
|
||||
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
|
||||
|
||||
## RED-first evidence
|
||||
|
||||
Observed against the unmodified `origin/main` implementation before source edits:
|
||||
|
||||
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
|
||||
- Success payload was reported `state=unknown`.
|
||||
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
|
||||
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
|
||||
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
|
||||
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
|
||||
- Audit-unavailable emitted no audit diagnostic.
|
||||
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
|
||||
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
|
||||
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
|
||||
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
|
||||
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
|
||||
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
|
||||
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
|
||||
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
|
||||
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
|
||||
|
||||
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
|
||||
- [x] Isolated worktree created and identity configured coherently.
|
||||
- [x] Mutant tests authored and observed red.
|
||||
- [x] Implementation green.
|
||||
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
|
||||
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
|
||||
- [ ] PR CI terminal-green at exact head by full step scan.
|
||||
- [ ] Merge-gate verdict issued against frozen head.
|
||||
|
||||
## Scope disposition
|
||||
|
||||
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
|
||||
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
|
||||
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
|
||||
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
|
||||
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
|
||||
|
||||
## Review remediation
|
||||
|
||||
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
|
||||
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
|
||||
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
|
||||
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
|
||||
|
||||
## Risks / boundaries
|
||||
|
||||
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
|
||||
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
|
||||
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
|
||||
|
||||
## Test evidence
|
||||
|
||||
Fresh after rescue checkpoint `b7175012`:
|
||||
|
||||
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
|
||||
- `bash -n` on the three production shell scripts passed.
|
||||
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
|
||||
- `pnpm typecheck` passed (45/45 Turbo tasks).
|
||||
- `pnpm lint` passed (25/25 Turbo tasks).
|
||||
- `pnpm format:check` passed.
|
||||
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
|
||||
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
|
||||
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
|
||||
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
|
||||
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
|
||||
|
||||
## Final evidence
|
||||
|
||||
Pending.
|
||||
+3
-6
@@ -6,14 +6,11 @@
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"preflight": "node scripts/preflight.mjs",
|
||||
"clean:generated": "node scripts/clean-generated.mjs",
|
||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||
"test:checkout": "node --test scripts/*.test.mjs",
|
||||
"test": "pnpm test:checkout && turbo run test",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
|
||||
@@ -13,14 +13,7 @@ It is a **gate** role: the one and only merge path.
|
||||
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
|
||||
**exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the
|
||||
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
|
||||
CI before merging). Before issuing a verdict, scan the full JSON/API child-step
|
||||
record (including `clone`) with **`verify-terminal-green.py --expect-commit
|
||||
<current-provider-PR-head>`** and record the equal expected/observed full-40
|
||||
commits, exact step count, anomalies, and named exemptions. Missing or mismatched
|
||||
commit binding is a hard refusal. The verifier's sole interim
|
||||
exemption is `WP-K8S-1000-CI-POSTGRES-TEARDOWN`; it is signature-scoped, tracked
|
||||
by #1000, and retires when #1000 is fixed. These scripts are the _only_
|
||||
sanctioned merge path.
|
||||
CI before merging). These two scripts are the _only_ sanctioned merge path.
|
||||
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
|
||||
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
|
||||
and `pr-ci-wait.sh`.
|
||||
|
||||
@@ -868,38 +868,6 @@ steps:
|
||||
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
|
||||
8. **Verify images appear** in Gitea Packages tab after successful pipeline
|
||||
|
||||
## Terminal-Green Full-Step Contract
|
||||
|
||||
A successful pipeline summary is not sufficient: verification MUST consume the full JSON/API child-step record, including `clone`.
|
||||
|
||||
```bash
|
||||
PR_HEAD=<full-40-hex-provider-head>
|
||||
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
|
||||
-r mosaicstack/stack -n <pipeline-number> -f json \
|
||||
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py \
|
||||
--expect-commit "$PR_HEAD" -
|
||||
```
|
||||
|
||||
`PR_HEAD` MUST come from the current provider PR metadata and MUST be the full 40-hex head, not a local branch guess. The verifier fails if the argument is missing, malformed, absent from the pipeline record, or differs from that record.
|
||||
|
||||
The verifier reports the expected and observed commits, total step count, state counts, anomalies, and any applied exemption. Exit `0` means the record satisfies the contract; exit `1` means the commit binding or at least one pipeline, workflow, or child-step state blocks terminal-green; exit `2` means the invocation or JSON input could not be verified.
|
||||
|
||||
### Named interim exemption: `WP-K8S-1000-CI-POSTGRES-TEARDOWN`
|
||||
|
||||
Only this exact conjunction is exempted:
|
||||
|
||||
- pipeline and workflow state are `success`;
|
||||
- exactly one non-success child exists;
|
||||
- its name is `ci-postgres` and type is `service`;
|
||||
- its state is `failure`, exit code is the JSON integer `0` (not boolean, float, string, or null); and
|
||||
- its error exactly matches `pods "wp-svc-<ULID>-ci-postgres" not found`.
|
||||
|
||||
Every near miss remains blocking, including non-zero service exits, startup failures, post-readiness crashes, connection errors, image-pull errors, skipped steps, another failed child, malformed pod names, duplicate matches, or a non-success pipeline/workflow.
|
||||
|
||||
**Boundary in both directions:** this exemption recognizes the observed Woodpecker Kubernetes reconciliation miss after an otherwise-successful run. It does not prove that every future PostgreSQL or Kubernetes failure is distinguishable. It does prove, through provider controls, that a deterministic startup failure (`exit_code=1`) and an armed post-readiness postmaster crash (`exit_code=137`, dependent probe `Connection refused`) do not match and remain red.
|
||||
|
||||
**Tracking and retirement:** [mosaicstack/stack#1000](https://git.mosaicstack.dev/mosaicstack/stack/issues/1000) owns the provider-seam fix. This exemption MUST be removed when #1000 is fixed. It is not authority to retry or re-trigger a pipeline, and no per-PR re-roll is part of the contract.
|
||||
|
||||
## Post-Merge CI Monitoring (Hard Rule)
|
||||
|
||||
For source-code delivery, completion is not allowed at "PR opened" stage.
|
||||
@@ -925,16 +893,14 @@ Woodpecker note:
|
||||
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
|
||||
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
|
||||
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
|
||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
||||
|
||||
## Gitea as Unified Platform
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
||||
- PR target for delivery is `main`.
|
||||
- Direct pushes to `main` are prohibited.
|
||||
- Merge to `main` MUST be squash-only.
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
|
||||
|
||||
## Review Checklist
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
|
||||
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
9. `push` - push immediately after queue guard passes.
|
||||
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
||||
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
||||
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
||||
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
|
||||
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
|
||||
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||
> without asking.
|
||||
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
||||
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
|
||||
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
|
||||
|
||||
@@ -425,11 +425,11 @@ git push
|
||||
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||
- Before merging, run queue guard:
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||
- Merge via wrapper:
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
- Wait for terminal CI status:
|
||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- Close linked issue after merge + green CI:
|
||||
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
|
||||
|
||||
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
|
||||
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
|
||||
## Git Scripts
|
||||
|
||||
@@ -638,9 +638,8 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
||||
|
||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
||||
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
||||
|
||||
|
||||
@@ -23,12 +23,10 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
|
||||
# Milestones
|
||||
~/.config/mosaic/tools/git/milestone-create.sh
|
||||
|
||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||
# CI queue guard (required before push/merge)
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||
```
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -88,7 +88,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -97,7 +97,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -101,7 +101,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -87,7 +87,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
+2
-2
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -84,7 +84,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
+2
-2
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
@@ -85,7 +85,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -7,9 +7,7 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
BRANCH=""
|
||||
TARGET_REPO=""
|
||||
HEAD_SHA=""
|
||||
BRANCH="main"
|
||||
TIMEOUT_SEC=900
|
||||
INTERVAL_SEC=15
|
||||
PURPOSE="merge"
|
||||
@@ -17,12 +15,10 @@ REQUIRE_STATUS=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
|
||||
Options:
|
||||
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
||||
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
|
||||
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
|
||||
-B, --branch BRANCH Branch head to inspect (default: main)
|
||||
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||
--purpose VALUE Log context: push|merge (default: merge)
|
||||
@@ -31,70 +27,77 @@ Options:
|
||||
|
||||
Examples:
|
||||
$(basename "$0")
|
||||
$(basename "$0") --purpose push -t 600 -i 10
|
||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
||||
EOF
|
||||
}
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
get_state_from_status_json() {
|
||||
# Python source comes from -c so the provider payload remains on stdin.
|
||||
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
|
||||
python3 -c '
|
||||
# Capture piped JSON BEFORE invoking `python3 - <<PY`. The heredoc binds
|
||||
# stdin to the Python program text — so json.load(sys.stdin) inside would
|
||||
# try to re-read stdin after `-` already consumed it for the program,
|
||||
# yielding EOF and returning "unknown" every time. Pass payload via env.
|
||||
local payload
|
||||
payload=$(cat)
|
||||
CI_QUEUE_STATUS_JSON="$payload" python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("status payload is not an object")
|
||||
payload = json.loads(os.environ.get("CI_QUEUE_STATUS_JSON", ""))
|
||||
except Exception:
|
||||
print("malformed")
|
||||
print("unknown")
|
||||
raise SystemExit(0)
|
||||
|
||||
raw_statuses = payload.get("statuses", [])
|
||||
raw_state = payload.get("state", "")
|
||||
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
statuses = raw_statuses
|
||||
state = raw_state.lower()
|
||||
statuses = payload.get("statuses") or []
|
||||
state = (payload.get("state") or "").lower()
|
||||
|
||||
pending_values = {"pending", "queued", "running", "waiting"}
|
||||
failure_values = {"failure", "error", "failed"}
|
||||
success_values = {"success"}
|
||||
|
||||
if state in pending_values:
|
||||
print("pending")
|
||||
raise SystemExit(0)
|
||||
if state in failure_values:
|
||||
print("terminal-failure")
|
||||
raise SystemExit(0)
|
||||
if state in success_values:
|
||||
print("terminal-success")
|
||||
raise SystemExit(0)
|
||||
|
||||
values = []
|
||||
for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
raw_value = item.get("status") or item.get("state")
|
||||
if not isinstance(raw_value, str) or not raw_value:
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
values.append(raw_value.lower())
|
||||
continue
|
||||
value = (item.get("status") or item.get("state") or "").lower()
|
||||
if value:
|
||||
values.append(value)
|
||||
|
||||
if any(value in pending_values for value in values) or state in pending_values:
|
||||
print("pending")
|
||||
elif any(value in failure_values for value in values) or state in failure_values:
|
||||
print("terminal-failure")
|
||||
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||
print("terminal-success")
|
||||
elif not values:
|
||||
if not values and not state:
|
||||
print("no-status")
|
||||
elif any(v in pending_values for v in values):
|
||||
print("pending")
|
||||
elif any(v in failure_values for v in values):
|
||||
print("terminal-failure")
|
||||
elif values and all(v in success_values for v in values):
|
||||
print("terminal-success")
|
||||
else:
|
||||
print("unknown")
|
||||
'
|
||||
PY
|
||||
}
|
||||
|
||||
print_pending_contexts() {
|
||||
python3 -c '
|
||||
# Same stdin hazard as get_state_from_status_json above — pass payload via env.
|
||||
local payload
|
||||
payload=$(cat)
|
||||
CI_QUEUE_STATUS_JSON="$payload" python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
payload = json.loads(os.environ.get("CI_QUEUE_STATUS_JSON", ""))
|
||||
except Exception:
|
||||
print("[ci-queue-wait] unable to decode status payload")
|
||||
raise SystemExit(0)
|
||||
@@ -110,61 +113,17 @@ for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("context") or item.get("name") or "unknown-context"
|
||||
value = str(item.get("status") or item.get("state") or "unknown").lower()
|
||||
value = (item.get("status") or item.get("state") or "unknown").lower()
|
||||
target = item.get("target_url") or item.get("url") or ""
|
||||
if value in pending_values:
|
||||
found = True
|
||||
suffix = f" ({target})" if target else ""
|
||||
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||
if target:
|
||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
||||
else:
|
||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
||||
if not found:
|
||||
print("[ci-queue-wait] no pending contexts")
|
||||
'
|
||||
}
|
||||
|
||||
record_cannot_assert() {
|
||||
local reason="$1"
|
||||
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
|
||||
|
||||
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
|
||||
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, reason, platform, purpose, branch, repo = sys.argv[1:]
|
||||
record = {
|
||||
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"outcome": "CANNOT_ASSERT",
|
||||
"reason": reason,
|
||||
"platform": platform,
|
||||
"purpose": purpose,
|
||||
"disposition": "hold" if purpose == "merge" else "degraded-pass",
|
||||
"branch": branch,
|
||||
"repo": repo,
|
||||
}
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
|
||||
finally:
|
||||
os.close(fd)
|
||||
PY
|
||||
then
|
||||
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
|
||||
return 70
|
||||
fi
|
||||
|
||||
if [[ "$PURPOSE" == "merge" ]]; then
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
|
||||
return 75
|
||||
fi
|
||||
|
||||
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
github_get_branch_head_sha() {
|
||||
@@ -178,87 +137,7 @@ github_get_commit_status_json() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local sha="$3"
|
||||
local work_root status_file checks_file
|
||||
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
|
||||
mkdir -p "$work_root" || return 1
|
||||
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
|
||||
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
|
||||
rm -f "$status_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
|
||||
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
|
||||
rm -f "$status_file" "$checks_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - "$status_file" "$checks_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||
status_pages = json.load(handle)
|
||||
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||
check_pages = json.load(handle)
|
||||
|
||||
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
|
||||
raise SystemExit(1)
|
||||
|
||||
# The statuses endpoint is newest-first and can contain retries for one context.
|
||||
# Keep only the newest entry per context after flattening every page.
|
||||
combined = []
|
||||
seen_contexts = set()
|
||||
for page in status_pages:
|
||||
if not isinstance(page, list):
|
||||
raise SystemExit(1)
|
||||
for status in page:
|
||||
if not isinstance(status, dict):
|
||||
raise SystemExit(1)
|
||||
context = status.get("context")
|
||||
if not isinstance(context, str) or not context or context in seen_contexts:
|
||||
continue
|
||||
seen_contexts.add(context)
|
||||
combined.append(status)
|
||||
|
||||
check_runs = []
|
||||
reported_total = 0
|
||||
for page in check_pages:
|
||||
if not isinstance(page, dict):
|
||||
raise SystemExit(1)
|
||||
page_runs = page.get("check_runs") or []
|
||||
total_count = page.get("total_count")
|
||||
if not isinstance(page_runs, list) or not isinstance(total_count, int):
|
||||
raise SystemExit(1)
|
||||
reported_total = max(reported_total, total_count)
|
||||
check_runs.extend(page_runs)
|
||||
if len(check_runs) < reported_total:
|
||||
raise SystemExit(1)
|
||||
|
||||
for run in check_runs:
|
||||
if not isinstance(run, dict):
|
||||
raise SystemExit(1)
|
||||
status = run.get("status")
|
||||
conclusion = run.get("conclusion")
|
||||
if status != "completed":
|
||||
value = "pending"
|
||||
elif conclusion == "success":
|
||||
value = "success"
|
||||
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
|
||||
value = "failure"
|
||||
else:
|
||||
value = "unknown"
|
||||
combined.append({
|
||||
"context": run.get("name") or "github-check",
|
||||
"status": value,
|
||||
"target_url": run.get("html_url") or run.get("details_url") or "",
|
||||
})
|
||||
|
||||
json.dump({"state": "", "statuses": combined}, sys.stdout)
|
||||
PY
|
||||
local status=$?
|
||||
rm -f "$status_file" "$checks_file"
|
||||
return "$status"
|
||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
||||
}
|
||||
|
||||
gitea_get_branch_head_sha() {
|
||||
@@ -304,14 +183,6 @@ while [[ $# -gt 0 ]]; do
|
||||
BRANCH="$2"
|
||||
shift 2
|
||||
;;
|
||||
-R|--repo)
|
||||
TARGET_REPO="$2"
|
||||
shift 2
|
||||
;;
|
||||
--sha)
|
||||
HEAD_SHA="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--timeout)
|
||||
TIMEOUT_SEC="$2"
|
||||
shift 2
|
||||
@@ -344,89 +215,45 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
||||
echo "Error: timeout and interval must be integer seconds." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
|
||||
echo "Error: --repo must be OWNER/REPO." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||
echo "Error: --purpose must be push or merge." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OWNER="unknown"
|
||||
REPO="unknown"
|
||||
PLATFORM="unknown"
|
||||
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
|
||||
record_cannot_assert "repository-owner-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
|
||||
record_cannot_assert "repository-name-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! detect_platform > /dev/null; then
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
fi
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
detect_platform > /dev/null
|
||||
PLATFORM="${PLATFORM:-unknown}"
|
||||
|
||||
if [[ -n "$TARGET_REPO" ]]; then
|
||||
OWNER="${TARGET_REPO%%/*}"
|
||||
REPO="${TARGET_REPO##*/}"
|
||||
fi
|
||||
|
||||
if [[ -z "$BRANCH" ]]; then
|
||||
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
|
||||
record_cannot_assert "current-branch-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! command -v gh >/dev/null 2>&1; then
|
||||
record_cannot_assert "github-cli-unavailable"
|
||||
exit $?
|
||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
||||
exit 1
|
||||
fi
|
||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||
record_cannot_assert "remote-host-unresolvable"
|
||||
exit $?
|
||||
fi
|
||||
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||
record_cannot_assert "credential-unresolvable"
|
||||
exit $?
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not determine remote host." >&2
|
||||
exit 1
|
||||
}
|
||||
TOKEN=$(get_gitea_token "$HOST") || {
|
||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||
exit 1
|
||||
}
|
||||
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
else
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
START_TS=$(date +%s)
|
||||
@@ -435,20 +262,14 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
||||
while true; do
|
||||
NOW_TS=$(date +%s)
|
||||
if (( NOW_TS > DEADLINE_TS )); then
|
||||
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
exit 124
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
||||
else
|
||||
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
||||
fi
|
||||
|
||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||
@@ -459,24 +280,21 @@ while true; do
|
||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
terminal-success)
|
||||
exit 0
|
||||
;;
|
||||
no-status)
|
||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||
else
|
||||
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 3
|
||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
||||
exit 0
|
||||
;;
|
||||
terminal-failure|malformed|unknown)
|
||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
terminal-success|terminal-failure|unknown)
|
||||
# Queue guard only blocks on pending/running/queued states.
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -499,17 +499,8 @@ get_gitea_url_for_host() {
|
||||
|
||||
# Resolve a Gitea API token for the given host.
|
||||
# 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() {
|
||||
local host="$1" _resolution_path=unresolved
|
||||
local host="$1"
|
||||
local script_dir
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
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_src="git config mosaic.gitIdentity"
|
||||
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
|
||||
local _idpfx=""
|
||||
case "$host" in
|
||||
@@ -543,23 +524,8 @@ get_gitea_token() {
|
||||
esac
|
||||
if [[ -n "$_idpfx" ]]; then
|
||||
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
|
||||
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
|
||||
if [[ -e "$_idcred" || -L "$_idcred" ]]; then
|
||||
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"
|
||||
if [[ -r "$_idtok" ]]; then
|
||||
cat "$_idtok"
|
||||
return 0
|
||||
fi
|
||||
# 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
|
||||
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
|
||||
# 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' \
|
||||
"$_ident" "$host" "$_ident_src" "$_idtok" >&2
|
||||
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_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
|
||||
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)
|
||||
if [[ -f "$cred_loader" ]]; then
|
||||
local token
|
||||
@@ -614,8 +571,6 @@ get_gitea_token() {
|
||||
echo "${GITEA_TOKEN:-}"
|
||||
)
|
||||
if [[ -n "$token" ]]; then
|
||||
_resolution_path=shared
|
||||
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
@@ -624,8 +579,6 @@ get_gitea_token() {
|
||||
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; 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"
|
||||
return 0
|
||||
fi
|
||||
@@ -637,8 +590,6 @@ get_gitea_token() {
|
||||
local token
|
||||
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
|
||||
if [[ -n "$token" ]]; then
|
||||
_resolution_path=shared
|
||||
_trace_credential_resolution shared-credential-resolved '<interactive-shared>' "$host" git-credentials
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -24,79 +24,29 @@ while IFS= read -r line; do
|
||||
username=*) username_in=${line#username=};;
|
||||
esac
|
||||
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).
|
||||
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
||||
# 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
|
||||
# the shared account. Backward-compatible: nothing resolvable → shared token.
|
||||
case "$host" in
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) idpfx="";;
|
||||
esac
|
||||
ident="$MOSAIC_GIT_IDENTITY"
|
||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
[ -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
|
||||
case "$host" in
|
||||
git.uscllc.com) idpfx=gitea-usc;;
|
||||
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
|
||||
*) idpfx="";;
|
||||
esac
|
||||
if [ -n "$idpfx" ]; then
|
||||
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
||||
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
|
||||
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
|
||||
if [ -r "$idtok" ]; then
|
||||
echo "username=${ident}"
|
||||
echo "password=${token}"
|
||||
echo "password=$(cat "$idtok")"
|
||||
exit 0
|
||||
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
|
||||
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
|
||||
git.uscllc.com) svc=gitea-usc;;
|
||||
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
||||
@@ -105,11 +55,10 @@ esac
|
||||
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
||||
# of where the framework installer places tools/ under $HOME — mirrors
|
||||
# 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
|
||||
source "$script_dir/../_lib/credentials.sh"
|
||||
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_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),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
MERGE_METHOD="squash"
|
||||
DELETE_BRANCH=false
|
||||
SKIP_QUEUE_GUARD=false
|
||||
DRY_RUN=false
|
||||
EXPECT_HEAD=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -25,14 +25,15 @@ Options:
|
||||
-n, --number NUMBER PR number to merge (required)
|
||||
-m, --method METHOD Merge method: squash only (default: squash)
|
||||
-d, --delete-branch Delete the head branch after merge
|
||||
--skip-queue-guard Skip CI queue guard wait before merge
|
||||
--dry-run Run metadata/login preflight without merging
|
||||
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -n 42 # Merge PR #42
|
||||
$(basename "$0") -n 42 -m squash # Squash merge
|
||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -52,13 +53,14 @@ while [[ $# -gt 0 ]]; do
|
||||
DELETE_BRANCH=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
--skip-queue-guard)
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--expect-head)
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
@@ -84,36 +86,18 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
|
||||
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" != true ]]; then
|
||||
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||
--purpose merge \
|
||||
-B "$HEAD_BRANCH" \
|
||||
-R "$HEAD_REPO" \
|
||||
--sha "$HEAD_SHA" \
|
||||
-B "$BASE_BRANCH" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
fi
|
||||
@@ -122,22 +106,31 @@ PLATFORM=$(detect_platform)
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
|
||||
is_known_tea_empty_identity_failure() {
|
||||
local error_file="$1"
|
||||
|
||||
python3 - "$error_file" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
||||
error = handle.read()
|
||||
|
||||
known_empty_identity = re.search(
|
||||
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
||||
error,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
raise SystemExit(0 if known_empty_identity else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
merge_gitea_with_api() {
|
||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
head_sha, delete_branch = sys.argv[1:]
|
||||
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||
if delete_branch == "true":
|
||||
payload["delete_branch_after_merge"] = True
|
||||
print(json.dumps(payload, separators=(",", ":")))
|
||||
PY
|
||||
)
|
||||
payload='{"Do":"squash"}'
|
||||
|
||||
token=$(get_gitea_token "$host" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
@@ -209,7 +202,7 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||
"${cmd[@]}"
|
||||
;;
|
||||
@@ -218,9 +211,32 @@ case "$PLATFORM" in
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||
# tea cannot express it, so exact-head merges use the authenticated API path.
|
||||
merge_gitea_with_api "$HOST"
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
|
||||
if [[ -n "$TEA_LOGIN" ]]; then
|
||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
|
||||
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
merge_gitea_with_api "$HOST"
|
||||
else
|
||||
cat "$TEA_ERROR_FILE" >&2
|
||||
rm -f "$TEA_ERROR_FILE"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
||||
merge_gitea_with_api "$HOST"
|
||||
fi
|
||||
|
||||
# Delete branch after merge if requested
|
||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
||||
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
|
||||
@@ -109,7 +109,7 @@ PY
|
||||
detect_platform > /dev/null
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
write_metadata "$METADATA"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
OWNER=$(get_repo_owner)
|
||||
@@ -182,25 +182,6 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
||||
data.get('head_ref'),
|
||||
head_ref,
|
||||
)
|
||||
head_sha = first_non_empty(
|
||||
nested(data, 'head', 'sha'),
|
||||
nested(data, 'head', 'id'),
|
||||
data.get('head_sha'),
|
||||
)
|
||||
head_repo = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'full_name'),
|
||||
nested(data, 'head', 'repo', 'name_with_owner'),
|
||||
)
|
||||
if not head_repo:
|
||||
head_repo_owner = first_non_empty(
|
||||
nested(data, 'head', 'repo', 'owner', 'login'),
|
||||
nested(data, 'head', 'repo', 'owner', 'username'),
|
||||
nested(data, 'head', 'repo', 'owner_name'),
|
||||
)
|
||||
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
|
||||
if head_repo_owner and head_repo_name:
|
||||
head_repo = f'{head_repo_owner}/{head_repo_name}'
|
||||
|
||||
base_ref = first_non_empty(
|
||||
nested(data, 'base', 'ref'),
|
||||
nested(data, 'base', 'name'),
|
||||
@@ -226,8 +207,6 @@ normalized = {
|
||||
'state': data.get('state'),
|
||||
'author': nested(data, 'user', 'login') or '',
|
||||
'headRefName': head_ref,
|
||||
'headRefOid': head_sha,
|
||||
'headRepository': head_repo,
|
||||
'baseRefName': base_ref,
|
||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||
|
||||
@@ -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")
|
||||
@@ -13,7 +13,7 @@
|
||||
# Covers:
|
||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
||||
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
||||
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -62,7 +62,7 @@ case "$mode" in
|
||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||
500) code=500; body='{"message":"internal server error"}' ;;
|
||||
no-status) code=200; body='{}' ;;
|
||||
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
terminal-success) code=200; body='{"state":"success"}' ;;
|
||||
*)
|
||||
echo "curl stub: unknown mode=$mode" >&2
|
||||
exit 2
|
||||
@@ -91,7 +91,6 @@ run_ci_queue_wait() {
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export GITEA_TOKEN="stub-token"
|
||||
export GITEA_URL="https://git.example.test"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
||||
)
|
||||
}
|
||||
@@ -132,26 +131,19 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
||||
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
||||
set +e
|
||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||
status_c=$?
|
||||
set -e
|
||||
if [[ "$status_c" -ne 0 ]]; then
|
||||
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
|
||||
if [[ "$status_c" -eq 0 ]]; then
|
||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||
echo "$out_c" >&2
|
||||
fail=1
|
||||
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
|
||||
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b fix/github-checks
|
||||
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
endpoint=""
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == repos/* ]] && endpoint="$arg"
|
||||
done
|
||||
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
|
||||
case "$endpoint" in
|
||||
repos/acme/widgets/branches/fix/github-checks)
|
||||
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
|
||||
;;
|
||||
repos/acme/widgets/commits/*/statuses?per_page=100)
|
||||
printf '%s\n' '[[]]'
|
||||
;;
|
||||
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
|
||||
case "${MOSAIC_GH_CHECK_MODE:?}" in
|
||||
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
|
||||
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
|
||||
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
|
||||
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
|
||||
*) exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/gh"
|
||||
|
||||
run_guard() {
|
||||
local mode="$1"
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_GH_CHECK_MODE="$mode"
|
||||
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
assert_case() {
|
||||
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
||||
set +e
|
||||
output=$(run_guard "$mode" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
||||
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
|
||||
failures=$((failures + 1))
|
||||
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
|
||||
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$output" != *"state=$expected_state"* ]]; then
|
||||
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/gh-calls.log"
|
||||
assert_case success zero terminal-success
|
||||
assert_case pending nonzero pending
|
||||
assert_case failure nonzero terminal-failure
|
||||
assert_case late-failure nonzero terminal-failure
|
||||
|
||||
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
||||
echo "FAIL: expected every case to query all Checks API pages" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "GitHub check-runs regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression suite for #1019: ci-queue-wait.sh's status parser must return a
|
||||
# state DERIVED FROM ITS INPUT.
|
||||
#
|
||||
# The defect this pins: both python sites piped a payload into `python3 - <<'PY'`.
|
||||
# The heredoc binds stdin to the Python program text, so json.load(sys.stdin) saw
|
||||
# EOF, the bare `except` fired, and the function returned "unknown" for every input
|
||||
# — success, pending and failure alike. "unknown" then reaches an `exit 0` arm, so
|
||||
# the gate-6 queue guard passed unconditionally on both the gitea and github paths.
|
||||
#
|
||||
# Every assertion below is on the RETURNED STATE STRING. A test that asserted only
|
||||
# `rc=0` would have passed against the broken build, which is why this bug survived.
|
||||
#
|
||||
# The functions are extracted from the shipped wrapper rather than reimplemented, so
|
||||
# this suite measures the code that actually runs.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Overridable so a mutation test can point the suite at a deliberately-broken copy
|
||||
# without touching the tracked file. The earlier version of this suite required
|
||||
# `git stash` + `git checkout` to do that, which left a repo-global stash entry
|
||||
# that any sibling worktree could have popped onto an unrelated branch.
|
||||
WRAPPER="${CI_QUEUE_WAIT_WRAPPER:-$SCRIPT_DIR/ci-queue-wait.sh}"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
pass() { printf ' PASS %s\n' "$1"; PASS=$((PASS + 1)); }
|
||||
fail() { printf ' FAIL %s\n' "$1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
if [[ ! -f "$WRAPPER" ]]; then
|
||||
printf 'FATAL: wrapper not found at %s\n' "$WRAPPER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMPDIR_T="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_T"' EXIT
|
||||
|
||||
extract_fn() {
|
||||
# $1 = function name, $2 = source file, $3 = destination
|
||||
sed -n "/^$1()/,/^}/p" "$2" > "$3"
|
||||
[[ -s "$3" ]] || { printf 'FATAL: could not extract %s from %s\n' "$1" "$2" >&2; exit 1; }
|
||||
}
|
||||
|
||||
extract_fn get_state_from_status_json "$WRAPPER" "$TMPDIR_T/state.sh"
|
||||
extract_fn print_pending_contexts "$WRAPPER" "$TMPDIR_T/contexts.sh"
|
||||
|
||||
state_of() {
|
||||
# shellcheck disable=SC1091
|
||||
( source "$TMPDIR_T/state.sh"; printf '%s' "$1" | get_state_from_status_json )
|
||||
}
|
||||
|
||||
expect_state() {
|
||||
local label="$1" payload="$2" want="$3" got
|
||||
got="$(state_of "$payload")"
|
||||
if [[ "$got" == "$want" ]]; then
|
||||
pass "$label -> $want"
|
||||
else
|
||||
fail "$label -> got '$got', want '$want'"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== parser returns a state derived from its input (#1019) ==="
|
||||
|
||||
expect_state "success payload" \
|
||||
'{"state":"success","statuses":[{"status":"success","context":"ci/build"}]}' \
|
||||
'terminal-success'
|
||||
|
||||
expect_state "pending payload" \
|
||||
'{"state":"pending","statuses":[{"status":"pending","context":"ci/build"}]}' \
|
||||
'pending'
|
||||
|
||||
expect_state "failure payload" \
|
||||
'{"state":"failure","statuses":[{"status":"failure","context":"ci/build"}]}' \
|
||||
'terminal-failure'
|
||||
|
||||
expect_state "mixed success+pending is pending" \
|
||||
'{"state":"pending","statuses":[{"status":"success"},{"status":"pending"}]}' \
|
||||
'pending'
|
||||
|
||||
expect_state "running counts as pending" \
|
||||
'{"state":"pending","statuses":[{"status":"running"}]}' \
|
||||
'pending'
|
||||
|
||||
expect_state "error counts as failure" \
|
||||
'{"state":"failure","statuses":[{"status":"error"}]}' \
|
||||
'terminal-failure'
|
||||
|
||||
expect_state "empty status set is no-status" \
|
||||
'{"state":"","statuses":[]}' \
|
||||
'no-status'
|
||||
|
||||
# Control. This is the ONE input for which "unknown" is correct. Without it, a
|
||||
# regression that hardcoded "unknown" again would still fail the cases above but
|
||||
# the suite would give no signal that "unknown" remains reachable when it should be.
|
||||
expect_state "undecodable payload stays unknown" \
|
||||
'not json at all' \
|
||||
'unknown'
|
||||
|
||||
expect_state "unrecognised status vocabulary is unknown" \
|
||||
'{"state":"weird","statuses":[{"status":"weird"}]}' \
|
||||
'unknown'
|
||||
|
||||
echo "=== pending contexts are reported to the operator ==="
|
||||
|
||||
contexts_of() {
|
||||
# shellcheck disable=SC1091
|
||||
( source "$TMPDIR_T/contexts.sh"; printf '%s' "$1" | print_pending_contexts )
|
||||
}
|
||||
|
||||
out="$(contexts_of '{"statuses":[{"status":"pending","context":"ci/alpha"},{"status":"pending","context":"ci/beta"}]}')"
|
||||
if grep -q 'ci/alpha' <<<"$out" && grep -q 'ci/beta' <<<"$out"; then
|
||||
pass "both pending contexts emitted"
|
||||
else
|
||||
fail "pending contexts not emitted; got: $out"
|
||||
fi
|
||||
|
||||
out="$(contexts_of '{"statuses":[{"status":"success","context":"ci/alpha"}]}')"
|
||||
# Assert the POSITIVE message, not merely the absence of the context name. Absence
|
||||
# alone is satisfied by total silence — and the pre-fix build was silent, so an
|
||||
# absence-only assertion passed against the very defect this suite exists to catch.
|
||||
if grep -q 'ci/alpha' <<<"$out"; then
|
||||
fail "a non-pending context was emitted; got: $out"
|
||||
elif grep -q 'no pending contexts' <<<"$out"; then
|
||||
pass "non-pending context suppressed, and reported as 'no pending contexts'"
|
||||
else
|
||||
fail "expected an explicit 'no pending contexts' report; got: $out"
|
||||
fi
|
||||
|
||||
echo "=== needle: the broken construct is caught, not merely absent today ==="
|
||||
|
||||
# Rebuild the pre-fix form and assert this suite would have failed against it.
|
||||
# Without this, the suite proves the current file is correct but not that it can
|
||||
# detect the defect returning.
|
||||
BROKEN="$TMPDIR_T/broken.sh"
|
||||
cat > "$BROKEN" <<'BROKEN_EOF'
|
||||
get_state_from_status_json() {
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("unknown")
|
||||
raise SystemExit(0)
|
||||
print("terminal-success" if (payload.get("state") or "") == "success" else "pending")
|
||||
PY
|
||||
}
|
||||
BROKEN_EOF
|
||||
|
||||
broken_got="$( ( source "$BROKEN"; printf '%s' '{"state":"success","statuses":[]}' | get_state_from_status_json ) )"
|
||||
if [[ "$broken_got" == "unknown" ]]; then
|
||||
pass "[NEEDLE ] pre-fix construct reproduces the defect (returns 'unknown' for a success payload)"
|
||||
else
|
||||
fail "[NEEDLE ] pre-fix construct did NOT reproduce the defect; got '$broken_got' — the needle no longer pins anything"
|
||||
fi
|
||||
|
||||
# And assert the shipped wrapper does not contain that construct.
|
||||
#
|
||||
# The check must have HEREDOC SEMANTICS, not merely match the text. `json.load(sys.stdin)`
|
||||
# is perfectly correct under `python3 -c '...'` — there the program comes from argv, so
|
||||
# stdin really is the payload, and ci-queue-wait.sh uses that form legitimately in
|
||||
# gitea_get_branch_head_sha. Only `python3 - <<DELIM` is defective, because `-` has
|
||||
# already bound stdin to the program text.
|
||||
#
|
||||
# A flat `grep json\.load\(sys\.stdin\)` therefore fails on correct code. It did: the
|
||||
# first version of this needle flagged the innocent `python3 -c` site. Same shape as
|
||||
# #1018 F1 (a regex over raw text has no syntax semantics) reproduced inside the needle
|
||||
# written to pin a different instance of it.
|
||||
heredoc_stdin_sites() {
|
||||
awk '
|
||||
/python3[[:space:]]+-[[:space:]]*<</ {
|
||||
d = $0
|
||||
sub(/.*<<[[:space:]]*/, "", d)
|
||||
gsub(/['"'"'"]/, "", d)
|
||||
delim = d; inhd = 1; next
|
||||
}
|
||||
inhd && $0 == delim { inhd = 0; next }
|
||||
inhd {
|
||||
line = $0
|
||||
sub(/[[:space:]]*#.*$/, "", line)
|
||||
if (line ~ /sys\.stdin/) printf "%d: %s\n", FNR, $0
|
||||
}
|
||||
' "$1"
|
||||
}
|
||||
|
||||
# Positive control FIRST. An empty result from a broken scanner looks exactly like a
|
||||
# clean file, so the scanner is proven to fire before its silence is trusted.
|
||||
if [[ -n "$(heredoc_stdin_sites "$BROKEN")" ]]; then
|
||||
pass "[NEEDLE ] scanner detects a stdin read inside a python3-heredoc"
|
||||
else
|
||||
fail "[NEEDLE ] scanner did NOT fire on the known-broken form — its silence proves nothing"
|
||||
fi
|
||||
|
||||
sites="$(heredoc_stdin_sites "$WRAPPER")"
|
||||
if [[ -n "$sites" ]]; then
|
||||
fail "[NEEDLE ] wrapper reads stdin inside a python3-heredoc at: $sites"
|
||||
else
|
||||
pass "[NEEDLE ] wrapper has no stdin read inside any python3-heredoc"
|
||||
fi
|
||||
|
||||
printf '\nci-queue-wait parse: %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
[[ "$FAIL" -eq 0 ]] || exit 1
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
|
||||
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
url=""
|
||||
has_write_out=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-w) has_write_out=1 ;;
|
||||
http://*|https://*) url="$arg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||
|
||||
case "$url" in
|
||||
*/branches/*)
|
||||
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||
exit 7
|
||||
fi
|
||||
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||
if [[ "$has_write_out" -eq 1 ]]; then
|
||||
printf '%s\n200' "$body"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
;;
|
||||
*/status)
|
||||
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
|
||||
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
|
||||
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
|
||||
malformed) printf '%s' 'not-json' ;;
|
||||
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
||||
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
||||
large-success)
|
||||
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
||||
;;
|
||||
unreachable) exit 7 ;;
|
||||
*) echo "unknown status mode" >&2; exit 2 ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
run_guard() {
|
||||
local status_mode="$1"
|
||||
local audit_log="${2:-$AUDIT_LOG}"
|
||||
shift 2 || true
|
||||
(
|
||||
cd "$REPO_DIR" || exit
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
if [[ "$status_mode" == "credential-unresolvable" ]]; then
|
||||
export HOME="$WORK_DIR/empty-home"
|
||||
mkdir -p "$HOME"
|
||||
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
|
||||
export MOSAIC_STUB_STATUS_MODE=success
|
||||
else
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||
fi
|
||||
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
|
||||
)
|
||||
}
|
||||
|
||||
failures=0
|
||||
run_assertion() {
|
||||
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||
local output rc
|
||||
shift 4
|
||||
set +e
|
||||
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
case "$expected_rc" in
|
||||
zero)
|
||||
if [[ "$rc" -ne 0 ]]; then
|
||||
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
nonzero)
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL $name: expected rc!=0, got rc=0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
not126)
|
||||
if [[ "$rc" -eq 126 ]]; then
|
||||
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [[ "$output" != *"$required_text"* ]]; then
|
||||
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
set -e
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion success zero success 'state=terminal-success'
|
||||
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
||||
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
||||
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||
run_assertion large-payload not126 large-success 'state=terminal-success'
|
||||
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||
|
||||
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
|
||||
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
|
||||
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||
set +e
|
||||
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
|
||||
merge_unreachable_rc=$?
|
||||
set -e
|
||||
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# A feature-branch push with no -B must inspect the checked-out feature branch.
|
||||
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
|
||||
exact_sha=0123456789abcdef0123456789abcdef01234567
|
||||
: > "$WORK_DIR/urls.log"
|
||||
run_assertion exact-fork-head zero success 'state=terminal-success' \
|
||||
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
|
||||
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
|
||||
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
|
||||
audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
|
||||
set +e
|
||||
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
|
||||
unsupported_rc=$?
|
||||
set -e
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
|
||||
if [[ "$unsupported_rc" -ne 0 ]]; then
|
||||
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
|
||||
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
|
||||
echo "FAIL unsupported-platform: expected an additional audit record" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
# A degraded pass is forbidden if the audit receipt cannot be written.
|
||||
mkdir -p "$WORK_DIR/not-a-directory"
|
||||
printf 'file' > "$WORK_DIR/not-a-directory/parent"
|
||||
set +e
|
||||
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
|
||||
audit_failure_rc=$?
|
||||
set -e
|
||||
if [[ "$audit_failure_rc" -eq 0 ]]; then
|
||||
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
if [[ "$audit_failure_output" != *"audit"* ]]; then
|
||||
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
|
||||
if [[ "$failures" -ne 0 ]]; then
|
||||
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
|
||||
@@ -16,7 +16,6 @@
|
||||
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
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" "")
|
||||
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
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 ->
|
||||
@@ -112,21 +93,6 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||
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=')"
|
||||
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.
|
||||
@@ -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: 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
|
||||
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||
@@ -179,45 +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=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Governed envelopes use the same binding, owner, mode, and digest checks.
|
||||
# ---------------------------------------------------------------------------
|
||||
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
|
||||
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
|
||||
# remotes, e.g. github.com via a different credential helper).
|
||||
# ---------------------------------------------------------------------------
|
||||
out=$(run_helper "github.com" "agentA")
|
||||
assert_eq "unknown host: 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).
|
||||
# ---------------------------------------------------------------------------
|
||||
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.
|
||||
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
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
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "interactive 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
|
||||
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA)
|
||||
assert_eq "confirmed fleet identity bypasses shared path" "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
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||
@@ -169,17 +143,13 @@ assert_failloud() {
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
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
|
||||
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" == *"shared-mosaicstack-token"* || "$stderr" == *"shared-usc-token"* ]]; then
|
||||
echo "FAIL: $desc — stderr unexpectedly contains a shared credential value:" >&2
|
||||
if [[ "$stderr" == *"shared"*"token"* ]]; then
|
||||
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
||||
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -79,24 +79,15 @@ emit_response() {
|
||||
printf '200'
|
||||
fi
|
||||
}
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
|
||||
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||
POST_DATA="$post_data" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
payload = json.loads(os.environ["POST_DATA"])
|
||||
assert payload == {
|
||||
"Do": "squash",
|
||||
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
|
||||
}, payload
|
||||
PY
|
||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
||||
echo "unexpected merge payload: $post_data" >&2
|
||||
exit 96
|
||||
fi
|
||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||
exit 0
|
||||
fi
|
||||
@@ -116,8 +107,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
||||
export GITEA_TOKEN="redacted-test-token"
|
||||
|
||||
OUTPUT="$SANDBOX/output.log"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
||||
echo "--- output ---" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
echo "--- mock log ---" >&2
|
||||
@@ -136,6 +127,38 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||
if [[ "$*" == *"login list"* ]]; then
|
||||
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$*" == *"pr merge"* ]]; then
|
||||
echo 'tea network timeout' >&2
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
: > "$LOG_FILE"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected arbitrary tea failure to remain blocking." >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
||||
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'tea network timeout' "$OUTPUT"; then
|
||||
echo "Expected arbitrary tea error to be preserved in output." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
@@ -154,8 +177,8 @@ EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
unset GITEA_LOGIN
|
||||
: > "$LOG_FILE"
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
@@ -192,7 +215,7 @@ cd "$REPO_DIR"
|
||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -217,7 +240,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||
: > "$LOG_FILE"
|
||||
rm -f "$SENTINEL"
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
exit 1
|
||||
@@ -237,4 +260,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge.sh Gitea exact-head API regression passed"
|
||||
echo "pr-merge.sh Gitea fallback regression passed"
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
|
||||
# The commit whose CI was guarded must be the commit the provider atomically merges.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
|
||||
SHA=0123456789abcdef0123456789abcdef01234567
|
||||
|
||||
make_fixture() {
|
||||
local name="$1" remote="$2"
|
||||
local root="$WORK_DIR/$name"
|
||||
local tools="$root/tools/git"
|
||||
mkdir -p "$tools" "$root/repo"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
||||
git -C "$root/repo" init -q
|
||||
git -C "$root/repo" remote add origin "$remote"
|
||||
cat > "$tools/pr-metadata.sh" <<SH
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$tools"/*.sh
|
||||
}
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
make_fixture gitea https://git.example.test/acme/widgets.git
|
||||
make_fixture github https://github.com/acme/widgets.git
|
||||
|
||||
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
payload=""
|
||||
for ((i=1; i<=$#; i++)); do
|
||||
if [[ "${!i}" == "-d" ]]; then
|
||||
j=$((i + 1))
|
||||
payload="${!j}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||
printf '200'
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/curl"
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
|
||||
) >"$WORK_DIR/gitea.out" 2>&1
|
||||
gitea_rc=$?
|
||||
set -e
|
||||
if [[ "$gitea_rc" -ne 0 ]]; then
|
||||
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
|
||||
cat "$WORK_DIR/gitea.out" >&2
|
||||
exit 1
|
||||
fi
|
||||
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
|
||||
assert payload.get("Do") == "squash", payload
|
||||
assert payload.get("head_commit_id") == sys.argv[2], payload
|
||||
PY
|
||||
|
||||
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
|
||||
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
|
||||
rm -f "$WORK_DIR/gitea-payload-stale.json"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
|
||||
) >"$WORK_DIR/gitea-stale.out" 2>&1
|
||||
stale_rc=$?
|
||||
set -e
|
||||
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
|
||||
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
|
||||
# --skip-queue-guard option must be rejected before any provider merge call.
|
||||
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 99
|
||||
SH
|
||||
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
|
||||
rm -f "$WORK_DIR/gitea-payload-bypass.json"
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
|
||||
) >"$WORK_DIR/gitea-bypass.out" 2>&1
|
||||
bypass_rc=$?
|
||||
set -e
|
||||
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
|
||||
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Dry-run is the only path that may omit the guard because it exits before the
|
||||
# provider merge dispatch. Prove the exit and absence of a merge payload.
|
||||
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
|
||||
(
|
||||
cd "$WORK_DIR/gitea/repo"
|
||||
export PATH="$WORK_DIR/gitea:$PATH"
|
||||
export GITEA_TOKEN=stub-token
|
||||
export GITEA_URL=https://git.example.test
|
||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
|
||||
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
|
||||
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
|
||||
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
|
||||
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > "$WORK_DIR/github/gh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
|
||||
SH
|
||||
chmod +x "$WORK_DIR/github/gh"
|
||||
(
|
||||
cd "$WORK_DIR/github/repo"
|
||||
export PATH="$WORK_DIR/github:$PATH"
|
||||
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
|
||||
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
|
||||
) >"$WORK_DIR/github.out" 2>&1
|
||||
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
|
||||
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
|
||||
cat "$WORK_DIR/github-call.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
|
||||
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FIXTURE_DIR"
|
||||
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||
|
||||
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||
exit 42
|
||||
SH
|
||||
chmod +x "$FIXTURE_DIR"/*.sh
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$WORK_DIR"
|
||||
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||
"$FIXTURE_DIR/pr-merge.sh" -n 123
|
||||
) >/dev/null 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
|
||||
if [[ "$rc" -ne 42 ]]; then
|
||||
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -s "$CALL_LOG" ]]; then
|
||||
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive PR head branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q -- '-B main' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard still received the main base branch" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
|
||||
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
|
||||
cat "$CALL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge queue branch/repository/SHA regression passed"
|
||||
@@ -26,13 +26,12 @@ A Woodpecker API token is required. To configure:
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
| -------------------------- | -------------------------------------------------------------- |
|
||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
||||
| `verify-terminal-green.py` | Verify every JSON/API child step under the bounded CI contract |
|
||||
| Script | Purpose |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
||||
|
||||
## Common Options
|
||||
|
||||
@@ -60,9 +59,4 @@ A Woodpecker API token is required. To configure:
|
||||
|
||||
# Block until one or more pipelines finish (event-driven CI wait)
|
||||
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
||||
|
||||
# Verify the full JSON child-step record; do not use the text summary for this gate
|
||||
PR_HEAD=<full-40-hex-provider-head>
|
||||
~/.config/mosaic/tools/woodpecker/pipeline-status.sh -r mosaicstack/stack -n 2188 -f json \
|
||||
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py --expect-commit "$PR_HEAD" -
|
||||
```
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Red-first contract harness for RM-61 / #1000.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERIFIER="$SCRIPT_DIR/verify-terminal-green.py"
|
||||
EXPECTED_COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
write_fixture() {
|
||||
local file="$1" pipeline_status="$2" postgres_state="$3" postgres_exit="$4" postgres_error="$5" test_state="$6"
|
||||
python3 - "$file" "$pipeline_status" "$postgres_state" "$postgres_exit" "$postgres_error" "$test_state" <<'PY'
|
||||
import json, sys
|
||||
path, pipeline_status, pg_state, pg_exit, pg_error, test_state = sys.argv[1:]
|
||||
steps = [
|
||||
{"name": "clone", "type": "clone", "state": "success", "exit_code": 0, "error": None},
|
||||
{"name": "ci-postgres", "type": "service", "state": pg_state, "exit_code": int(pg_exit), "error": pg_error or None},
|
||||
{"name": "test", "type": "commands", "state": test_state, "exit_code": 0 if test_state == "success" else 1, "error": None},
|
||||
]
|
||||
json.dump({
|
||||
"number": 9999,
|
||||
"status": pipeline_status,
|
||||
"commit": "a" * 40,
|
||||
"workflows": [{"name": "ci", "state": pipeline_status, "children": steps}],
|
||||
}, open(path, "w"))
|
||||
PY
|
||||
}
|
||||
|
||||
expect_exit() {
|
||||
local expected_exit="$1" label="$2" file="$3" expected_commit="${4:-$EXPECTED_COMMIT}"
|
||||
set +e
|
||||
output=$(python3 "$VERIFIER" --expect-commit "$expected_commit" "$file" 2>&1)
|
||||
actual=$?
|
||||
set -e
|
||||
if [[ "$actual" -ne "$expected_exit" ]]; then
|
||||
printf 'FAIL %s: expected exit %s, got %s\n%s\n' "$label" "$expected_exit" "$actual" "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'PASS %s\n' "$label"
|
||||
printf '%s' "$output"
|
||||
}
|
||||
|
||||
# Ordinary terminal green.
|
||||
write_fixture "$TMP/green.json" success success 0 '' success
|
||||
out=$(expect_exit 0 green "$TMP/green.json")
|
||||
grep -q '"total_steps": 3' <<<"$out"
|
||||
grep -q '"exempted_steps": 0' <<<"$out"
|
||||
|
||||
# Exact, named #1000 teardown artifact: the only permitted non-success child.
|
||||
artifact='pods "wp-svc-01kyxzjhdf6w81swsnbfzh85z9-ci-postgres" not found'
|
||||
write_fixture "$TMP/artifact.json" success failure 0 "$artifact" success
|
||||
out=$(expect_exit 0 exact-artifact "$TMP/artifact.json")
|
||||
grep -q '"exemption_id": "WP-K8S-1000-CI-POSTGRES-TEARDOWN"' <<<"$out"
|
||||
grep -q '"exempted_steps": 1' <<<"$out"
|
||||
|
||||
# Negative controls: both real PostgreSQL failures must remain red.
|
||||
write_fixture "$TMP/startup.json" failure failure 1 '' failure
|
||||
expect_exit 1 startup-failure "$TMP/startup.json" >/dev/null
|
||||
write_fixture "$TMP/crash.json" failure failure 137 '' failure
|
||||
expect_exit 1 post-readiness-crash "$TMP/crash.json" >/dev/null
|
||||
|
||||
# The exemption is signature-scoped, not step-scoped.
|
||||
write_fixture "$TMP/wrong-error.json" success failure 0 'connection refused' success
|
||||
expect_exit 1 other-postgres-error "$TMP/wrong-error.json" >/dev/null
|
||||
write_fixture "$TMP/wrong-pod.json" success failure 0 'pods "other-ci-postgres" not found' success
|
||||
expect_exit 1 wrong-pod-signature "$TMP/wrong-pod.json" >/dev/null
|
||||
write_fixture "$TMP/nonzero-artifact.json" success failure 137 "$artifact" success
|
||||
expect_exit 1 nonzero-with-artifact-text "$TMP/nonzero-artifact.json" >/dev/null
|
||||
|
||||
# JSON booleans and non-integer zero look equal to 0 in Python but are not exit codes.
|
||||
python3 - "$TMP/artifact.json" "$TMP" <<'PY'
|
||||
import json, os, sys
|
||||
record = json.load(open(sys.argv[1]))
|
||||
for label, value in (("false", False), ("true", True), ("float", 0.0), ("string", "0"), ("null", None)):
|
||||
changed = json.loads(json.dumps(record))
|
||||
changed["workflows"][0]["children"][1]["exit_code"] = value
|
||||
json.dump(changed, open(os.path.join(sys.argv[2], f"exit-{label}.json"), "w"))
|
||||
PY
|
||||
for label in false true float string null; do
|
||||
expect_exit 1 "non-integer-exit-$label" "$TMP/exit-$label.json" >/dev/null
|
||||
done
|
||||
|
||||
# Exact artifact cannot mask any independent failure or non-success pipeline.
|
||||
write_fixture "$TMP/artifact-plus-failure.json" failure failure 0 "$artifact" failure
|
||||
expect_exit 1 artifact-plus-real-failure "$TMP/artifact-plus-failure.json" >/dev/null
|
||||
write_fixture "$TMP/skipped.json" success success 0 '' skipped
|
||||
expect_exit 1 skipped-step "$TMP/skipped.json" >/dev/null
|
||||
|
||||
# The scanned pipeline must be bound to an explicit, full PR-head commit.
|
||||
set +e
|
||||
missing_output=$(python3 "$VERIFIER" "$TMP/artifact.json" 2>&1)
|
||||
missing_rc=$?
|
||||
set -e
|
||||
if [[ "$missing_rc" -ne 2 ]] || ! grep -q -- '--expect-commit' <<<"$missing_output"; then
|
||||
printf 'FAIL missing-expected-commit: expected usage exit 2\n%s\n' "$missing_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
expect_exit 1 mismatched-expected-commit "$TMP/artifact.json" bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb >/dev/null
|
||||
|
||||
python3 - "$TMP/artifact.json" "$TMP/missing-record-commit.json" <<'PY'
|
||||
import json, sys
|
||||
record = json.load(open(sys.argv[1]))
|
||||
record.pop("commit")
|
||||
json.dump(record, open(sys.argv[2], "w"))
|
||||
PY
|
||||
expect_exit 1 missing-record-commit "$TMP/missing-record-commit.json" >/dev/null
|
||||
|
||||
printf 'terminal-green contract harness: PASS (17 cases)\n'
|
||||
@@ -1,230 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify Mosaic's full-step Woodpecker terminal-green contract.
|
||||
|
||||
RM-61 permits one named, signature-scoped exception for issue #1000. The
|
||||
exception retires when #1000 is fixed; all other non-success states block.
|
||||
This program consumes the JSON/API record emitted by pipeline-status.sh -f json.
|
||||
It does not fetch, retry, or re-trigger pipelines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
EXEMPTION_ID = "WP-K8S-1000-CI-POSTGRES-TEARDOWN"
|
||||
EXEMPTION_ISSUE = "https://git.mosaicstack.dev/mosaicstack/stack/issues/1000"
|
||||
POD_NOT_FOUND = re.compile(
|
||||
r'^pods "wp-svc-[0-9a-hjkmnp-tv-z]{26}-ci-postgres" not found$'
|
||||
)
|
||||
|
||||
|
||||
def fail_usage(message: str) -> int:
|
||||
print(f"terminal-green contract input error: {message}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
def load_record(argument: str | None) -> dict[str, Any]:
|
||||
if argument in (None, "-"):
|
||||
value = json.load(sys.stdin)
|
||||
else:
|
||||
with Path(argument).open(encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("pipeline record must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def is_issue_1000_artifact(step: dict[str, Any]) -> bool:
|
||||
error = step.get("error")
|
||||
exit_code = step.get("exit_code")
|
||||
return (
|
||||
step.get("name") == "ci-postgres"
|
||||
and step.get("type") == "service"
|
||||
and step.get("state") == "failure"
|
||||
and type(exit_code) is int
|
||||
and not isinstance(exit_code, bool)
|
||||
and exit_code == 0
|
||||
and isinstance(error, str)
|
||||
and POD_NOT_FOUND.fullmatch(error) is not None
|
||||
)
|
||||
|
||||
|
||||
def verify(record: dict[str, Any], expected_commit: str) -> tuple[int, dict[str, Any]]:
|
||||
anomalies: list[dict[str, Any]] = []
|
||||
candidates: list[dict[str, Any]] = []
|
||||
steps: list[dict[str, Any]] = []
|
||||
|
||||
pipeline_status = record.get("status")
|
||||
actual_commit = record.get("commit")
|
||||
if actual_commit != expected_commit:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "pipeline",
|
||||
"name": str(record.get("number", "unknown")),
|
||||
"state": pipeline_status,
|
||||
"reason": "pipeline commit does not equal the expected PR head",
|
||||
"expected_commit": expected_commit,
|
||||
"actual_commit": actual_commit,
|
||||
}
|
||||
)
|
||||
if pipeline_status != "success":
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "pipeline",
|
||||
"name": str(record.get("number", "unknown")),
|
||||
"state": pipeline_status,
|
||||
"reason": "pipeline status is not success",
|
||||
}
|
||||
)
|
||||
|
||||
workflows = record.get("workflows")
|
||||
if not isinstance(workflows, list) or not workflows:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "pipeline",
|
||||
"name": str(record.get("number", "unknown")),
|
||||
"state": pipeline_status,
|
||||
"reason": "workflows are missing or empty",
|
||||
}
|
||||
)
|
||||
workflows = []
|
||||
|
||||
for workflow_index, workflow in enumerate(workflows):
|
||||
if not isinstance(workflow, dict):
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "workflow",
|
||||
"name": str(workflow_index),
|
||||
"state": None,
|
||||
"reason": "workflow is not an object",
|
||||
}
|
||||
)
|
||||
continue
|
||||
workflow_name = str(workflow.get("name", workflow_index))
|
||||
if workflow.get("state") != "success":
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "workflow",
|
||||
"name": workflow_name,
|
||||
"state": workflow.get("state"),
|
||||
"reason": "workflow state is not success",
|
||||
}
|
||||
)
|
||||
children = workflow.get("children")
|
||||
if not isinstance(children, list) or not children:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "workflow",
|
||||
"name": workflow_name,
|
||||
"state": workflow.get("state"),
|
||||
"reason": "child-step list is missing or empty",
|
||||
}
|
||||
)
|
||||
continue
|
||||
for child_index, child in enumerate(children):
|
||||
if not isinstance(child, dict):
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "step",
|
||||
"name": f"{workflow_name}[{child_index}]",
|
||||
"state": None,
|
||||
"reason": "step is not an object",
|
||||
}
|
||||
)
|
||||
continue
|
||||
steps.append(child)
|
||||
if child.get("state") == "success":
|
||||
continue
|
||||
if is_issue_1000_artifact(child):
|
||||
candidates.append(child)
|
||||
continue
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "step",
|
||||
"name": child.get("name"),
|
||||
"type": child.get("type"),
|
||||
"state": child.get("state"),
|
||||
"exit_code": child.get("exit_code"),
|
||||
"error": child.get("error"),
|
||||
"reason": "non-success step does not match the #1000 teardown signature",
|
||||
}
|
||||
)
|
||||
|
||||
if len(candidates) > 1:
|
||||
anomalies.append(
|
||||
{
|
||||
"scope": "exemption",
|
||||
"name": EXEMPTION_ID,
|
||||
"state": "invalid",
|
||||
"reason": "the #1000 exemption may apply to exactly one step",
|
||||
}
|
||||
)
|
||||
|
||||
exemption_applies = len(candidates) == 1 and not anomalies
|
||||
state_counts = Counter(str(step.get("state", "missing")) for step in steps)
|
||||
result: dict[str, Any] = {
|
||||
"schema_version": "mosaic-terminal-green/v1",
|
||||
"verdict": "terminal-green" if not anomalies else "not-terminal-green",
|
||||
"pipeline_number": record.get("number"),
|
||||
"commit": actual_commit,
|
||||
"expected_commit": expected_commit,
|
||||
"pipeline_status": pipeline_status,
|
||||
"total_steps": len(steps),
|
||||
"state_counts": dict(sorted(state_counts.items())),
|
||||
"exempted_steps": 1 if exemption_applies else 0,
|
||||
"anomalies": anomalies,
|
||||
}
|
||||
if exemption_applies:
|
||||
candidate = candidates[0]
|
||||
result["exemptions"] = [
|
||||
{
|
||||
"exemption_id": EXEMPTION_ID,
|
||||
"step": candidate.get("name"),
|
||||
"signature": candidate.get("error"),
|
||||
"tracking_issue": EXEMPTION_ISSUE,
|
||||
"retires_when": "issue #1000 is fixed",
|
||||
}
|
||||
]
|
||||
else:
|
||||
result["exemptions"] = []
|
||||
|
||||
return (0 if not anomalies else 1), result
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="verify the full Woodpecker terminal-green child-step contract"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expect-commit",
|
||||
required=True,
|
||||
metavar="FULL_SHA",
|
||||
help="full 40-hex PR-head commit that the pipeline record must match",
|
||||
)
|
||||
parser.add_argument("record", nargs="?", default="-", help="pipeline JSON file or -")
|
||||
arguments = parser.parse_args()
|
||||
if re.fullmatch(r"[0-9a-fA-F]{40}", arguments.expect_commit) is None:
|
||||
parser.error("--expect-commit must be a full 40-hex commit")
|
||||
arguments.expect_commit = arguments.expect_commit.lower()
|
||||
return arguments
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = parse_arguments()
|
||||
try:
|
||||
record = load_record(arguments.record)
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
return fail_usage(str(error))
|
||||
code, result = verify(record, arguments.expect_commit)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.49",
|
||||
"version": "0.0.48",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-parse.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -14,7 +14,6 @@ import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerInteractionCommand } from './commands/interaction.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
import { registerCredentialCommand } from './commands/cred.js';
|
||||
import { registerFleetCommand } from './commands/fleet.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||
@@ -372,10 +371,6 @@ registerInteractionCommand(program);
|
||||
|
||||
registerFleetCommand(program);
|
||||
|
||||
// ─── credential governance ─────────────────────────────────────────────
|
||||
|
||||
registerCredentialCommand(program);
|
||||
|
||||
// ─── config ────────────────────────────────────────────────────────────
|
||||
|
||||
registerConfigCommand(program);
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} 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,
|
||||
listCredentialJournals,
|
||||
} from '../credentials/audit-journal.js';
|
||||
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
|
||||
import { FileCredentialStore } from '../credentials/file-credential-store.js';
|
||||
import { 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('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,57 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CredentialJournalSummaryDto {
|
||||
readonly id: string;
|
||||
readonly state: 'open' | 'sealed';
|
||||
readonly path: string;
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, 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('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,314 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { 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 } 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 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-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 syncDirectory(path: string): Promise<void> {
|
||||
const directory = await open(path, 'r');
|
||||
try {
|
||||
await directory.sync();
|
||||
} finally {
|
||||
await directory.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class CredentialAuditJournal {
|
||||
private closed = false;
|
||||
|
||||
private constructor(
|
||||
private readonly handle: FileHandle,
|
||||
private readonly openPath: string,
|
||||
private readonly journalsDirectory: string,
|
||||
private readonly id: string,
|
||||
private readonly now: () => string,
|
||||
) {}
|
||||
|
||||
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');
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
ensureManagedDirectory(stateRoot, journalsDirectory);
|
||||
assertPrivateDirectory(stateRoot);
|
||||
assertPrivateDirectory(journalsDirectory);
|
||||
const openPath = join(journalsDirectory, `${id}.open.jsonl`);
|
||||
handle = await open(openPath, 'wx', 0o600);
|
||||
const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now);
|
||||
await journal.append({ phase: 'opened', at: now(), context });
|
||||
await syncDirectory(journalsDirectory);
|
||||
return journal;
|
||||
} catch (error: unknown) {
|
||||
if (handle !== undefined) await handle.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 {
|
||||
await this.handle.write(`${JSON.stringify(record)}\n`);
|
||||
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',
|
||||
);
|
||||
}
|
||||
await this.append({ phase: 'sealed', at: this.now(), outcome, reasonCode });
|
||||
await this.handle.close();
|
||||
this.closed = true;
|
||||
const sealedPath = join(this.journalsDirectory, `${this.id}.sealed.jsonl`);
|
||||
try {
|
||||
await rename(this.openPath, sealedPath);
|
||||
await syncDirectory(this.journalsDirectory);
|
||||
return sealedPath;
|
||||
} catch {
|
||||
throw new CredentialJournalError(
|
||||
'journal-unavailable',
|
||||
'sealed journal could not be committed durably',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async closeIncomplete(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
await this.handle.close();
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listCredentialJournals(
|
||||
stateRoot: string,
|
||||
): Promise<readonly CredentialJournalSummaryDto[]> {
|
||||
const journalsDirectory = join(stateRoot, 'journals');
|
||||
let names: string[];
|
||||
try {
|
||||
assertPrivateDirectory(stateRoot);
|
||||
assertPrivateDirectory(journalsDirectory);
|
||||
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 names
|
||||
.filter((name: string): boolean => /\.(?:open|sealed)\.jsonl$/.test(name))
|
||||
.sort()
|
||||
.map((name: string): CredentialJournalSummaryDto => {
|
||||
const state = name.endsWith('.open.jsonl') ? 'open' : 'sealed';
|
||||
return {
|
||||
id: name.replace(/\.(?:open|sealed)\.jsonl$/, ''),
|
||||
state,
|
||||
path: join(journalsDirectory, name),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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,160 +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('atomically stores, lists, reads binding metadata, and removes a governed credential', 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('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,504 +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'
|
||||
);
|
||||
}
|
||||
|
||||
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): 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 {
|
||||
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;
|
||||
}
|
||||
| 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',
|
||||
);
|
||||
}
|
||||
return { binding, secret: new Uint8Array(resolved.secret) };
|
||||
}
|
||||
|
||||
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.readBinding(identity, estate, host);
|
||||
if (current === undefined || current.tokenDigest !== expectedTokenDigest) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-generation-mismatch',
|
||||
'credential generation changed before removal',
|
||||
);
|
||||
}
|
||||
}
|
||||
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,214 +0,0 @@
|
||||
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
import { parseCredentialEstateRegistry } from './estate-registry.js';
|
||||
import { FileCredentialStore } from './file-credential-store.js';
|
||||
import { provisionCredential, revokeCredential, type GiteaLifecycleProvider } from './lifecycle.js';
|
||||
import { TeaLoginStore } from './tea-login-store.js';
|
||||
|
||||
let cleanup: string | undefined;
|
||||
afterEach(async (): Promise<void> => {
|
||||
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('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('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('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,364 +0,0 @@
|
||||
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
||||
import type { ResolvedCredential } from './credential-provider.dto.js';
|
||||
import 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;
|
||||
}
|
||||
|
||||
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 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,
|
||||
});
|
||||
await journal.recordIntent(`${operation}-requested`);
|
||||
return journal;
|
||||
}
|
||||
|
||||
export async function provisionCredential(
|
||||
request: ProvisionRequest,
|
||||
authority: ResolvedCredential,
|
||||
provider: GiteaLifecycleProvider,
|
||||
store: FileCredentialStore,
|
||||
teaStore: TeaLoginStore,
|
||||
options: LifecycleOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const journal = options.journal ?? (await openLifecycleJournal('provision', request, options));
|
||||
let mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
let minted: MintedToken | undefined;
|
||||
let failureCode = 'mutation-state-unknown';
|
||||
const prior = await store.snapshot(request.identity, request.estate, request.host);
|
||||
if (prior !== undefined && options.allowReplace !== true) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
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');
|
||||
}
|
||||
await store.put(
|
||||
{
|
||||
identity: request.identity,
|
||||
estate: request.estate,
|
||||
host: request.host,
|
||||
providerLogin: identity.login,
|
||||
tokenName: request.tokenName,
|
||||
scopes: readBack.scopes,
|
||||
createdAt: options.now?.() ?? new Date().toISOString(),
|
||||
},
|
||||
minted.secret,
|
||||
);
|
||||
await journal.recordMutation('token-binding-stored');
|
||||
await teaStore.put(request.identity, request.host, minted.secret);
|
||||
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');
|
||||
}
|
||||
if (prior === undefined) {
|
||||
await store.remove(request.identity, request.estate, request.host);
|
||||
await teaStore.remove(request.identity, request.host).catch((): void => undefined);
|
||||
} else {
|
||||
await store.put(prior.binding, prior.secret);
|
||||
await teaStore.put(request.identity, request.host, prior.secret);
|
||||
}
|
||||
rollbackComplete = true;
|
||||
} catch {
|
||||
rollbackComplete = false;
|
||||
} finally {
|
||||
prior?.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);
|
||||
}
|
||||
}
|
||||
|
||||
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 mutation: 'none' | 'unknown' | 'applied' = 'none';
|
||||
try {
|
||||
const binding = await store.readBinding(request.identity, request.estate, request.host);
|
||||
if (binding === 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 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);
|
||||
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, binding.tokenDigest);
|
||||
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) {
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { chmod, mkdtemp, readFile, 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 } 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('serializes concurrent updates and preserves the same 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('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,246 +0,0 @@
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { open, rename, unlink } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
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;
|
||||
}
|
||||
|
||||
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 class TeaLoginStore {
|
||||
constructor(private readonly configPath: string) {}
|
||||
|
||||
async put(identity: string, host: string, secret: Uint8Array): 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;
|
||||
}
|
||||
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);
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temp, this.configPath);
|
||||
await syncDirectory(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): Promise<void> {
|
||||
const directory = dirname(this.configPath);
|
||||
const lockPath = `${this.configPath}.lock`;
|
||||
const lock = await acquireLock(lockPath);
|
||||
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');
|
||||
}
|
||||
const logins = decoded.data.logins.filter(
|
||||
(login): boolean =>
|
||||
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
|
||||
);
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(stringify({ ...decoded.data, logins }), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await rename(temp, this.configPath);
|
||||
await syncDirectory(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,613 +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 } from 'vitest';
|
||||
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> => {
|
||||
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('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,524 +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>;
|
||||
}
|
||||
export interface TeamGrantOptions {
|
||||
readonly stateRoot: string;
|
||||
readonly actor: 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 rollbackTeam: TeamResolutionEvidence | undefined;
|
||||
let membershipBeforeMutation: PresenceEvidence | undefined;
|
||||
let repositoryAttachedBeforeMutation = false;
|
||||
let memberMutationAttempted = false;
|
||||
let repositoryMutationAttempted = false;
|
||||
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 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)) {
|
||||
await journal.seal('refused', 'team-scope-exceeds-request');
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
'refused',
|
||||
'none',
|
||||
'team-scope-exceeds-request',
|
||||
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');
|
||||
}
|
||||
}
|
||||
await journal.seal('indeterminate', 'team-scope-changed-during-grant');
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
'indeterminate',
|
||||
'applied',
|
||||
'team-scope-changed-during-grant',
|
||||
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;
|
||||
await journal.seal(
|
||||
ok ? 'ok' : 'indeterminate',
|
||||
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
||||
);
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
ok ? 'ok' : 'indeterminate',
|
||||
'applied',
|
||||
ok ? 'grant-verified' : 'permission-evidence-disagrees',
|
||||
validation,
|
||||
team,
|
||||
teamMembership,
|
||||
teamRepository,
|
||||
organizationMembership,
|
||||
finalTeamRepositorySet,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
let compensationError: unknown;
|
||||
try {
|
||||
if (
|
||||
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 (
|
||||
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';
|
||||
try {
|
||||
await journal.seal('indeterminate', reasonCode);
|
||||
} catch (journalError: unknown) {
|
||||
if (journalError instanceof CredentialJournalError) {
|
||||
throw new CredentialGrantExecutionError(journalError.code, mutation, journal.journalId());
|
||||
}
|
||||
throw journalError;
|
||||
}
|
||||
return result(
|
||||
request,
|
||||
journal,
|
||||
'indeterminate',
|
||||
mutation,
|
||||
reasonCode,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
// The kernel also releases this advisory lock on process exit. A close
|
||||
// cleanup fault must not contradict an already sealed provider verdict.
|
||||
await releaseTeamLock?.().catch((): void => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
dev: number | bigint;
|
||||
ino: number | bigint;
|
||||
uid: number;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
function sameIdentity(
|
||||
@@ -237,8 +235,6 @@ export function readRegularFileSecure(
|
||||
mode: Number(opened.mode),
|
||||
dev: opened.dev,
|
||||
ino: opened.ino,
|
||||
uid: opened.uid,
|
||||
gid: opened.gid,
|
||||
};
|
||||
} finally {
|
||||
closeDescriptors(openedFile.descriptors);
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs';
|
||||
|
||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function run(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(signal ? `next build terminated by ${signal}` : `next build exited ${code}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function requireRealDirectory(target, { allowMissing = false } = {}) {
|
||||
try {
|
||||
const stats = await lstat(target);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
throw new Error(`${target} must be a real directory, not a symbolic link.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (allowMissing && error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireBuildLock(root) {
|
||||
const workRoot = path.join(root, '.mosaic-test-work');
|
||||
const lock = path.join(workRoot, 'web-build.lock');
|
||||
const nonce = randomUUID();
|
||||
const owner = JSON.stringify({ pid: process.pid, nonce });
|
||||
const deadline = Date.now() + 120_000;
|
||||
await mkdir(workRoot, { recursive: true });
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await mkdir(lock);
|
||||
await writeFile(path.join(lock, 'owner.json'), owner, { mode: 0o600 });
|
||||
return async () => {
|
||||
const current = await readFile(path.join(lock, 'owner.json'), 'utf8');
|
||||
if (current !== owner) throw new Error('Web build lock ownership changed before release.');
|
||||
const released = `${lock}.released-${nonce}`;
|
||||
await rename(lock, released);
|
||||
await rm(released, { recursive: true, force: true });
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
let lockOwner;
|
||||
try {
|
||||
lockOwner = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8'));
|
||||
} catch (ownerError) {
|
||||
if (ownerError.code === 'ENOENT') {
|
||||
await delay(25);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Web build lock is unreadable at ${lock}.`, { cause: ownerError });
|
||||
}
|
||||
try {
|
||||
process.kill(lockOwner.pid, 0);
|
||||
} catch (processError) {
|
||||
if (processError.code !== 'ESRCH') throw processError;
|
||||
const stale = `${lock}.stale-${nonce}`;
|
||||
try {
|
||||
await rename(lock, stale);
|
||||
await rm(stale, { recursive: true, force: true });
|
||||
} catch (renameError) {
|
||||
if (renameError.code !== 'ENOENT') throw renameError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await delay(25);
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for the web build lock at ${lock}.`);
|
||||
}
|
||||
|
||||
export async function buildWeb({
|
||||
root = scriptRoot,
|
||||
fingerprint = sourceFingerprint,
|
||||
runBuild = async (webDir) =>
|
||||
run(path.join(webDir, 'node_modules', '.bin', 'next'), ['build'], {
|
||||
cwd: webDir,
|
||||
stdio: 'inherit',
|
||||
}),
|
||||
} = {}) {
|
||||
const releaseLock = await acquireBuildLock(root);
|
||||
try {
|
||||
const webDir = path.join(root, 'apps', 'web');
|
||||
const nextDir = path.join(webDir, '.next');
|
||||
const certificationMarker = path.join(nextDir, '.mosaic-source-hash');
|
||||
const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest');
|
||||
const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`;
|
||||
const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`;
|
||||
const before = await fingerprint(root);
|
||||
|
||||
await requireRealDirectory(nextDir, { allowMissing: true });
|
||||
await Promise.all([
|
||||
rm(certificationMarker, { force: true }),
|
||||
rm(symlinkManifest, { force: true }),
|
||||
]);
|
||||
await runBuild(webDir);
|
||||
await requireRealDirectory(nextDir);
|
||||
|
||||
const after = await fingerprint(root);
|
||||
if (after !== before) {
|
||||
throw new Error(
|
||||
'Web build inputs changed during next build; generated output was not certified.',
|
||||
);
|
||||
}
|
||||
|
||||
const manifestContents = await generatedSymlinkManifest(nextDir);
|
||||
const certificationContents = `${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint: before,
|
||||
symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'),
|
||||
})}\n`;
|
||||
await Promise.all([
|
||||
writeFile(certificationTemporary, certificationContents, { mode: 0o600 }),
|
||||
writeFile(manifestTemporary, manifestContents, { mode: 0o600 }),
|
||||
]);
|
||||
// The certification marker is the commit point. Publishing the manifest first
|
||||
// leaves interrupted builds untrusted because the marker remains absent.
|
||||
await rename(manifestTemporary, symlinkManifest);
|
||||
await rename(certificationTemporary, certificationMarker);
|
||||
} finally {
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await buildWeb();
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildWeb } from './build-web.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `build-web-${process.pid}`);
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, 'apps', 'web', '.next'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('a successful web build atomically publishes its source and symlink certification', async () => {
|
||||
const root = await fixture('success');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
|
||||
await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} });
|
||||
|
||||
assert.deepEqual(JSON.parse(await readFile(marker, 'utf8')), {
|
||||
version: 1,
|
||||
sourceFingerprint: 'certified',
|
||||
symlinkManifestHash: '8a5a375cea6a55d24bd5f875856da63feba33adbefb15a92a0007719b84bcf11',
|
||||
});
|
||||
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||
});
|
||||
|
||||
test('a failed web build leaves no certification marker', async () => {
|
||||
const root = await fixture('failure');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
await writeFile(marker, 'stale\n');
|
||||
await writeFile(manifest, 'stale\n');
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'before',
|
||||
runBuild: async () => {
|
||||
throw new Error('build failed');
|
||||
},
|
||||
}),
|
||||
/build failed/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
});
|
||||
|
||||
test('overlapping web builds are serialized while the marker remains absent', async () => {
|
||||
const root = await fixture('overlap');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
await writeFile(marker, 'stale\n');
|
||||
await writeFile(manifest, 'stale\n');
|
||||
let releaseFirst;
|
||||
let secondEntered = false;
|
||||
const firstEntered = new Promise((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let markFirstEntered;
|
||||
const firstStarted = new Promise((resolve) => {
|
||||
markFirstEntered = resolve;
|
||||
});
|
||||
|
||||
const first = buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
markFirstEntered();
|
||||
await firstEntered;
|
||||
},
|
||||
});
|
||||
await firstStarted;
|
||||
const second = buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
secondEntered = true;
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
assert.equal(secondEntered, false);
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(secondEntered, true);
|
||||
assert.equal(JSON.parse(await readFile(marker, 'utf8')).sourceFingerprint, 'certified');
|
||||
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||
});
|
||||
|
||||
test('a build that replaces .next with a symbolic link cannot publish outside the checkout', async () => {
|
||||
const root = await fixture('symbolic-next');
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
const outside = path.join(root, 'outside-generated');
|
||||
await mkdir(outside);
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
await rm(nextDir, { recursive: true });
|
||||
await symlink(outside, nextDir);
|
||||
},
|
||||
}),
|
||||
/must be a real directory/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(path.join(outside, '.mosaic-source-hash')), false);
|
||||
assert.equal(await exists(path.join(outside, '.mosaic-symlink-manifest')), false);
|
||||
});
|
||||
|
||||
test('inputs changed during a web build are not certified', async () => {
|
||||
const root = await fixture('changed-inputs');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
const fingerprints = ['before', 'after'];
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => fingerprints.shift(),
|
||||
runBuild: async () => {},
|
||||
}),
|
||||
/inputs changed during next build/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access, mkdir, rename, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const generated = path.join(root, 'apps', 'web', '.next');
|
||||
const quarantineRoot = path.join(root, '.mosaic-test-work', 'generated-quarantine');
|
||||
|
||||
try {
|
||||
await access(generated);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') process.exit(0);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await mkdir(quarantineRoot, { recursive: true });
|
||||
const quarantine = path.join(quarantineRoot, `web-next-${Date.now()}-${process.pid}`);
|
||||
try {
|
||||
await rename(generated, quarantine);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`MOSAIC_GENERATED_CLEAN_FAILED: could not quarantine apps/web/.next. Fix: sudo rm -rf '${generated}', then rerun pnpm preflight`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(quarantine, { recursive: true, force: true });
|
||||
} catch {
|
||||
console.warn(
|
||||
`Generated state was deactivated but could not be deleted; quarantined at ${quarantine}`,
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function run(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(signal ? `husky terminated by ${signal}` : `husky exited ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function directorySnapshot(root) {
|
||||
const snapshot = [];
|
||||
async function walk(current) {
|
||||
const children = await readdir(current, { withFileTypes: true });
|
||||
for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const target = path.join(current, child.name);
|
||||
const relative = path.relative(root, target);
|
||||
const stats = await lstat(target);
|
||||
if (child.isDirectory()) {
|
||||
snapshot.push([relative, 'directory', stats.mode & 0o777]);
|
||||
await walk(target);
|
||||
} else {
|
||||
snapshot.push([
|
||||
relative,
|
||||
'file',
|
||||
stats.mode & 0o777,
|
||||
(await readFile(target)).toString('base64'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
async function directoriesMatch(left, right) {
|
||||
return (await directorySnapshot(left)) === (await directorySnapshot(right));
|
||||
}
|
||||
|
||||
export async function installHooks({
|
||||
root = process.cwd(),
|
||||
disabled = process.env.HUSKY === '0',
|
||||
quarantineRoot = path.join(root, '.mosaic-test-work', 'husky-quarantine'),
|
||||
runHusky = async (_stagingHooks, stagingRepo) => {
|
||||
await execFileAsync('git', ['init', '--quiet', stagingRepo]);
|
||||
await run(path.join(root, 'node_modules', '.bin', 'husky'), ['.husky'], {
|
||||
cwd: stagingRepo,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
},
|
||||
activateHooks = async () => {
|
||||
await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' });
|
||||
},
|
||||
} = {}) {
|
||||
if (disabled) return;
|
||||
|
||||
const huskyDir = path.join(root, '.husky');
|
||||
const active = path.join(huskyDir, '_');
|
||||
const nonce = `${Date.now()}-${process.pid}`;
|
||||
const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`);
|
||||
const stagingHooks = path.join(stagingRepo, '.husky');
|
||||
const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`);
|
||||
await mkdir(huskyDir, { recursive: true });
|
||||
await mkdir(quarantineRoot, { recursive: true });
|
||||
|
||||
const previousComplete = (await pathExists(active)) && (await pathExists(path.join(active, 'h')));
|
||||
let previousQuarantined = false;
|
||||
try {
|
||||
if ((await pathExists(active)) && !previousComplete) {
|
||||
await rename(active, quarantined);
|
||||
previousQuarantined = true;
|
||||
}
|
||||
await mkdir(stagingRepo, { recursive: true });
|
||||
await runHusky(stagingHooks, stagingRepo);
|
||||
const staged = path.join(stagingHooks, '_');
|
||||
if (!(await pathExists(path.join(staged, 'h')))) {
|
||||
throw new Error('husky did not produce its required h shim');
|
||||
}
|
||||
if (previousComplete) {
|
||||
if (!(await directoriesMatch(active, staged))) {
|
||||
throw new Error('existing complete hook set differs from the installed Husky version');
|
||||
}
|
||||
await rm(stagingRepo, { recursive: true, force: true });
|
||||
} else {
|
||||
await rename(staged, active);
|
||||
await rm(stagingRepo, { recursive: true, force: true });
|
||||
}
|
||||
await activateHooks();
|
||||
if (previousQuarantined) {
|
||||
try {
|
||||
await rm(quarantined, { recursive: true, force: true });
|
||||
} catch {
|
||||
console.warn(
|
||||
`Previous hook state was deactivated but remains quarantined at ${quarantined}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const cleanupFailures = [];
|
||||
try {
|
||||
if (await pathExists(stagingRepo)) {
|
||||
await rename(stagingRepo, `${quarantined}-staging`);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(`staging hooks: ${cleanupError.message}`);
|
||||
}
|
||||
const cleanup =
|
||||
cleanupFailures.length === 0
|
||||
? 'No partial hook set was activated.'
|
||||
: `Automatic cleanup was incomplete (${cleanupFailures.join('; ')}).`;
|
||||
throw new Error(
|
||||
`Hook installation failed: ${error.message}. ${cleanup} Fix: rm -rf .husky/_ && git config core.hooksPath .husky/_ && pnpm install --frozen-lockfile`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
await installHooks();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { installHooks } from './install-hooks.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `hooks-${process.pid}`);
|
||||
const quarantineRoot = path.join(fixtureRoot, 'quarantine');
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, '.husky'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('an interrupted install quarantines the partial active hook set and fails loudly', async () => {
|
||||
const root = await fixture('interrupted');
|
||||
let restoredHooksPath = 'not-called';
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot,
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'partial');
|
||||
throw new Error('simulated interruption');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async (value) => {
|
||||
restoredHooksPath = value;
|
||||
},
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Hook installation failed/);
|
||||
assert.match(error.message, /pnpm install --frozen-lockfile/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(await exists(path.join(root, '.husky', '_')), false);
|
||||
assert.equal(restoredHooksPath, 'not-called');
|
||||
const quarantined = await readdir(quarantineRoot);
|
||||
assert.equal(quarantined.length, 1);
|
||||
});
|
||||
|
||||
test('a failed replacement restores a previously complete active hook set', async () => {
|
||||
const root = await fixture('rollback');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'previous-complete');
|
||||
let previousRemainedActiveDuringStaging = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'rollback-quarantine'),
|
||||
runHusky: async () => {
|
||||
previousRemainedActiveDuringStaging =
|
||||
(await readFile(activeShim, 'utf8')) === 'previous-complete';
|
||||
throw new Error('simulated replacement failure');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => '.husky/_',
|
||||
restoreHooksPath: async () => {},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(previousRemainedActiveDuringStaging, true);
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'previous-complete');
|
||||
});
|
||||
|
||||
test('a mismatched complete hook set fails loudly instead of reporting a stale install as current', async () => {
|
||||
const root = await fixture('mismatch');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'old-complete');
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'mismatch-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'new-complete');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => '.husky/_',
|
||||
restoreHooksPath: async () => {},
|
||||
}),
|
||||
/Hook installation failed.*pnpm install --frozen-lockfile/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'old-complete');
|
||||
});
|
||||
|
||||
test('a competing successful installer is not removed by the losing process', async () => {
|
||||
const root = await fixture('concurrent');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
let restored = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'concurrent-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'ours');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'peer');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {
|
||||
restored = true;
|
||||
},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'peer');
|
||||
assert.equal(restored, false);
|
||||
});
|
||||
|
||||
test("a competing installer that replaces this installer's active set is preserved", async () => {
|
||||
const root = await fixture('concurrent-after-rename');
|
||||
const active = path.join(root, '.husky', '_');
|
||||
const activeShim = path.join(active, 'h');
|
||||
let restored = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'concurrent-after-rename-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'ours');
|
||||
},
|
||||
activateHooks: async () => {
|
||||
await rm(active, { recursive: true, force: true });
|
||||
await mkdir(active, { recursive: true });
|
||||
await writeFile(activeShim, 'peer');
|
||||
throw new Error('our activation lost to peer');
|
||||
},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {
|
||||
restored = true;
|
||||
},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'peer');
|
||||
assert.equal(restored, false);
|
||||
});
|
||||
|
||||
test('an explicit interactive HUSKY=0 opt-out preserves existing hooks without running installer', async () => {
|
||||
const root = await fixture('disabled');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'preserved');
|
||||
let ran = false;
|
||||
|
||||
await installHooks({
|
||||
root,
|
||||
disabled: true,
|
||||
runHusky: async () => {
|
||||
ran = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(ran, false);
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'preserved');
|
||||
});
|
||||
|
||||
test('a successful install leaves a complete active hook set', async () => {
|
||||
const root = await fixture('success');
|
||||
|
||||
await installHooks({
|
||||
root,
|
||||
quarantineRoot,
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'complete');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {},
|
||||
});
|
||||
|
||||
assert.equal(await exists(path.join(root, '.husky', '_', 'h')), true);
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { access, lstat, readFile, readdir, readlink } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
export const MISSING_DEPS_EXIT = 42;
|
||||
export const GENERATED_STATE_EXIT = 43;
|
||||
|
||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
async function entries(root) {
|
||||
const result = [];
|
||||
async function walk(current) {
|
||||
let children;
|
||||
try {
|
||||
children = await readdir(current, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
for (const child of children) {
|
||||
const target = path.join(current, child.name);
|
||||
result.push(target);
|
||||
if (child.isDirectory() && !child.isSymbolicLink()) await walk(target);
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generatedSymlinkManifest(nextDir) {
|
||||
const links = [];
|
||||
for (const target of (await entries(nextDir)).sort()) {
|
||||
const stats = await lstat(target);
|
||||
if (!stats.isSymbolicLink()) continue;
|
||||
links.push({
|
||||
path: path.relative(nextDir, target).split(path.sep).join('/'),
|
||||
target: await readlink(target),
|
||||
});
|
||||
}
|
||||
return `${JSON.stringify({ version: 1, links })}\n`;
|
||||
}
|
||||
|
||||
const webSourceRoots = (root) => [
|
||||
path.join(root, 'apps', 'web', 'src'),
|
||||
path.join(root, 'apps', 'web', 'public'),
|
||||
path.join(root, 'apps', 'web', 'next-env.d.ts'),
|
||||
path.join(root, 'apps', 'web', 'next.config.ts'),
|
||||
path.join(root, 'apps', 'web', 'postcss.config.mjs'),
|
||||
path.join(root, 'apps', 'web', 'package.json'),
|
||||
path.join(root, 'apps', 'web', 'tsconfig.json'),
|
||||
path.join(root, 'packages', 'design-tokens', 'src'),
|
||||
path.join(root, 'packages', 'design-tokens', 'package.json'),
|
||||
path.join(root, 'packages', 'design-tokens', 'tsconfig.json'),
|
||||
path.join(root, 'package.json'),
|
||||
path.join(root, 'tsconfig.base.json'),
|
||||
path.join(root, 'pnpm-lock.yaml'),
|
||||
path.join(root, 'pnpm-workspace.yaml'),
|
||||
path.join(root, 'turbo.json'),
|
||||
];
|
||||
|
||||
// next.config.ts currently reads no server-only environment. Add any future
|
||||
// server-side build inputs here; all resolved NEXT_PUBLIC_* inputs are automatic.
|
||||
const serverBuildEnvironmentKeys = [];
|
||||
|
||||
function publicBuildEnvironment(root) {
|
||||
const webDir = path.join(root, 'apps', 'web');
|
||||
const requireFromWeb = createRequire(path.join(scriptRoot, 'apps', 'web', 'package.json'));
|
||||
const requireFromNext = createRequire(requireFromWeb.resolve('next/package.json'));
|
||||
const { loadEnvConfig, resetEnv, updateInitialEnv } = requireFromNext('@next/env');
|
||||
const originalEnvironment = { ...process.env };
|
||||
updateInitialEnv(originalEnvironment);
|
||||
try {
|
||||
const { combinedEnv } = loadEnvConfig(webDir, false, { info() {}, error() {} }, true);
|
||||
return Object.fromEntries(
|
||||
Object.entries(combinedEnv).filter(
|
||||
([key, value]) =>
|
||||
value !== undefined &&
|
||||
(key.startsWith('NEXT_PUBLIC_') || serverBuildEnvironmentKeys.includes(key)),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
resetEnv();
|
||||
}
|
||||
}
|
||||
|
||||
export async function sourceFingerprint(root = process.cwd()) {
|
||||
const files = [];
|
||||
for (const sourceRoot of webSourceRoots(root)) {
|
||||
try {
|
||||
const stats = await lstat(sourceRoot);
|
||||
if (stats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Web build input must not be a symbolic link: ${path.relative(root, sourceRoot)}`,
|
||||
);
|
||||
}
|
||||
if (stats.isFile()) files.push(sourceRoot);
|
||||
if (stats.isDirectory()) {
|
||||
for (const target of await entries(sourceRoot)) {
|
||||
const targetStats = await lstat(target);
|
||||
if (targetStats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Web build input must not be a symbolic link: ${path.relative(root, target)}`,
|
||||
);
|
||||
}
|
||||
if (targetStats.isFile()) files.push(target);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const digest = createHash('sha256');
|
||||
for (const [key, value] of Object.entries(publicBuildEnvironment(root)).sort()) {
|
||||
digest.update(`env:${key}\0${value.length}\0${value}\0`);
|
||||
}
|
||||
for (const target of files.sort()) {
|
||||
const contents = await readFile(target);
|
||||
digest.update(path.relative(root, target).split(path.sep).join('/'));
|
||||
digest.update('\0');
|
||||
digest.update(String(contents.length));
|
||||
digest.update('\0');
|
||||
digest.update(contents);
|
||||
digest.update('\0');
|
||||
}
|
||||
return digest.digest('hex');
|
||||
}
|
||||
|
||||
export async function runPreflight({ root = process.cwd(), uid = process.getuid?.() } = {}) {
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
const requiredBinaries = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
||||
const missingBinaries = [];
|
||||
for (const binary of requiredBinaries) {
|
||||
try {
|
||||
await access(path.join(binDir, binary), constants.X_OK);
|
||||
} catch {
|
||||
missingBinaries.push(binary);
|
||||
}
|
||||
}
|
||||
if (missingBinaries.length > 0) {
|
||||
return {
|
||||
code: MISSING_DEPS_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_MISSING_DEPS: dependency installation is missing ${missingBinaries.join(', ')}; run pnpm install --frozen-lockfile`,
|
||||
};
|
||||
}
|
||||
|
||||
const buildLock = path.join(root, '.mosaic-test-work', 'web-build.lock');
|
||||
try {
|
||||
await lstat(buildLock);
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_GENERATED_STATE: web build is in progress or interrupted at ${buildLock}; wait for it to finish or rerun pnpm build to recover the stale lock`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
let generated = [];
|
||||
try {
|
||||
const nextStats = await lstat(nextDir);
|
||||
if (!nextStats.isDirectory() || nextStats.isSymbolicLink()) {
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message:
|
||||
'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next must be a real directory, not a symbolic link, and is not trustworthy; run pnpm clean:generated, then rerun the gate',
|
||||
};
|
||||
}
|
||||
generated = [nextDir, ...(await entries(nextDir))];
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
if (generated.length > 0) {
|
||||
const foreign = [];
|
||||
for (const target of generated) {
|
||||
const stats = await lstat(target);
|
||||
if (uid !== undefined && stats.uid !== uid) foreign.push(path.relative(root, target));
|
||||
}
|
||||
|
||||
// Detects accidental, independent, stale, and foreign-residue mutation of
|
||||
// generated state: the class this check was born from was a five-month-stale
|
||||
// .next whose validator referenced deleted pages and produced 19 phantom TS2307
|
||||
// errors indistinguishable from real type errors.
|
||||
//
|
||||
// Does NOT defend against an actor with same-UID write access to the generated
|
||||
// tree, which can regenerate both the manifest and marker consistently
|
||||
// (CWE-345). No local construction can, absent a trust anchor outside that
|
||||
// actor's authority. RM-59 tracks executor/spine-side attestation.
|
||||
let certification = null;
|
||||
let certifiedManifest = null;
|
||||
try {
|
||||
const [certificationContents, manifestContents] = await Promise.all([
|
||||
readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8'),
|
||||
readFile(path.join(nextDir, '.mosaic-symlink-manifest'), 'utf8'),
|
||||
]);
|
||||
try {
|
||||
const parsed = JSON.parse(certificationContents);
|
||||
if (
|
||||
parsed.version === 1 &&
|
||||
typeof parsed.sourceFingerprint === 'string' &&
|
||||
typeof parsed.symlinkManifestHash === 'string'
|
||||
) {
|
||||
certification = parsed;
|
||||
certifiedManifest = manifestContents;
|
||||
}
|
||||
} catch {
|
||||
// Invalid certification is handled as untrusted generated state below.
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
const stale = certification?.sourceFingerprint !== (await sourceFingerprint(root));
|
||||
const actualManifest = await generatedSymlinkManifest(nextDir);
|
||||
const certifiedManifestHash =
|
||||
certifiedManifest === null
|
||||
? null
|
||||
: createHash('sha256').update(certifiedManifest).digest('hex');
|
||||
const changedSymlinks =
|
||||
certification?.symlinkManifestHash !== certifiedManifestHash ||
|
||||
certifiedManifest !== actualManifest;
|
||||
if (foreign.length > 0 || stale || changedSymlinks) {
|
||||
const reasons = [
|
||||
foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '',
|
||||
stale ? 'generated source fingerprint does not match web source/configuration' : '',
|
||||
changedSymlinks
|
||||
? 'generated symbolic-link manifest does not match the certified build'
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next is not trustworthy (${reasons.join('; ')}); run pnpm clean:generated, then rerun the gate`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { code: 0, message: 'checkout preflight passed' };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await runPreflight();
|
||||
const stream = result.code === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${result.message}\n`);
|
||||
process.exitCode = result.code;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { runPreflight, sourceFingerprint } from './preflight.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`);
|
||||
|
||||
const requiredBins = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, 'apps', 'web', 'src', 'app'), { recursive: true });
|
||||
await writeFile(path.join(root, 'apps', 'web', 'src', 'app', 'page.tsx'), 'export default 1;\n');
|
||||
return root;
|
||||
}
|
||||
|
||||
async function installRequiredBins(root) {
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await Promise.all(
|
||||
requiredBins.map(async (name) => {
|
||||
const target = path.join(binDir, name);
|
||||
await writeFile(target, '');
|
||||
await chmod(target, 0o755);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function certifyGeneratedState(root, links = []) {
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
await mkdir(nextDir, { recursive: true });
|
||||
const manifest = `${JSON.stringify({ version: 1, links })}\n`;
|
||||
const manifestHash = createHash('sha256').update(manifest).digest('hex');
|
||||
await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest);
|
||||
await writeFile(
|
||||
path.join(nextDir, '.mosaic-source-hash'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint: await sourceFingerprint(root),
|
||||
symlinkManifestHash: manifestHash,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('missing dependencies have a dedicated exit code and install remediation', async () => {
|
||||
const root = await fixture('missing-deps');
|
||||
const result = await runPreflight({ root });
|
||||
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_MISSING_DEPS/);
|
||||
assert.match(result.message, /run pnpm install/i);
|
||||
});
|
||||
|
||||
test('a partial dependency install keeps the dedicated missing-deps result', async () => {
|
||||
const root = await fixture('partial-deps');
|
||||
await mkdir(path.join(root, 'node_modules', '.bin'), { recursive: true });
|
||||
await writeFile(path.join(root, 'node_modules', '.bin', 'tsc'), '', { mode: 0o755 });
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /turbo/);
|
||||
});
|
||||
|
||||
test('a dangling required dependency shim keeps the dedicated missing-deps result', async () => {
|
||||
const root = await fixture('dangling-deps');
|
||||
await installRequiredBins(root);
|
||||
const turbo = path.join(root, 'node_modules', '.bin', 'turbo');
|
||||
await rm(turbo);
|
||||
await symlink(path.join(root, 'node_modules', 'missing-turbo'), turbo);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /turbo/);
|
||||
});
|
||||
|
||||
test('installed dependencies pass when generated state is absent', async () => {
|
||||
const root = await fixture('clean');
|
||||
await installRequiredBins(root);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||
});
|
||||
|
||||
test('foreign-owned generated Next state is identified separately from source errors', async () => {
|
||||
const root = await fixture('foreign-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'generated output');
|
||||
|
||||
const result = await runPreflight({ root, uid: (process.getuid?.() ?? 0) + 1 });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /foreign-owned/);
|
||||
});
|
||||
|
||||
test('a generated marker mismatch is identified separately from source errors', async () => {
|
||||
const root = await fixture('stale-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'stale generated output');
|
||||
await writeFile(path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), 'old-source');
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /apps\/web\/\.next/);
|
||||
assert.match(result.message, /pnpm clean:generated/);
|
||||
});
|
||||
|
||||
test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => {
|
||||
await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => {
|
||||
const root = await fixture('symbolic-next-root');
|
||||
await installRequiredBins(root);
|
||||
await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n');
|
||||
await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next'));
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /symbolic link/);
|
||||
});
|
||||
|
||||
await t.test('apps/web/.next is rejected when it is not a directory', async () => {
|
||||
const root = await fixture('non-directory-next-root');
|
||||
await installRequiredBins(root);
|
||||
await writeFile(path.join(root, 'apps', 'web', '.next'), 'not a Next build\n');
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /real directory/);
|
||||
});
|
||||
|
||||
await t.test('an added descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-added');
|
||||
await installRequiredBins(root);
|
||||
await certifyGeneratedState(root);
|
||||
await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink'));
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a removed certified descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-removed');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../dependency-one', link);
|
||||
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||
await rm(link);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a retargeted certified descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-retargeted');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../dependency-one', link);
|
||||
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||
await rm(link);
|
||||
await symlink('../dependency-two', link);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-tampered-manifest');
|
||||
await installRequiredBins(root);
|
||||
await certifyGeneratedState(root);
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink'));
|
||||
await writeFile(
|
||||
path.join(nextDir, '.mosaic-symlink-manifest'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }],
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('unchanged canonical-style descendant symlinks are accepted', async () => {
|
||||
const root = await fixture('symbolic-next-certified');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(
|
||||
root,
|
||||
'apps',
|
||||
'web',
|
||||
'.next',
|
||||
'standalone',
|
||||
'node_modules',
|
||||
'dependency',
|
||||
);
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../.pnpm/dependency', link);
|
||||
await certifyGeneratedState(root, [
|
||||
{ path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), {
|
||||
code: 0,
|
||||
message: 'checkout preflight passed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('the source fingerprint includes inherited TypeScript configuration', async () => {
|
||||
const root = await fixture('inherited-typescript-config');
|
||||
const config = path.join(root, 'tsconfig.base.json');
|
||||
await writeFile(config, '{"compilerOptions":{"strict":true}}\n');
|
||||
const first = await sourceFingerprint(root);
|
||||
await writeFile(config, '{"compilerOptions":{"strict":false}}\n');
|
||||
const second = await sourceFingerprint(root);
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test('the source fingerprint rejects symbolic-link build inputs', async () => {
|
||||
const root = await fixture('symbolic-source');
|
||||
await writeFile(path.join(root, 'outside.ts'), 'export default 1;\n');
|
||||
await symlink(path.join(root, 'outside.ts'), path.join(root, 'apps', 'web', 'src', 'linked.ts'));
|
||||
|
||||
await assert.rejects(sourceFingerprint(root), /must not be a symbolic link/);
|
||||
});
|
||||
|
||||
test('the source fingerprint includes expanded public web build environment', async () => {
|
||||
const root = await fixture('public-build-environment');
|
||||
const envFile = path.join(root, 'apps', 'web', '.env.production');
|
||||
await writeFile(
|
||||
envFile,
|
||||
'RM01_GATEWAY_URL=https://one.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
|
||||
);
|
||||
const first = await sourceFingerprint(root);
|
||||
await writeFile(
|
||||
envFile,
|
||||
'RM01_GATEWAY_URL=https://two.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
|
||||
);
|
||||
const second = await sourceFingerprint(root);
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test('a matching generation marker accepts incremental output with mixed mtimes', async () => {
|
||||
const root = await fixture('incremental-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'unchanged generated output');
|
||||
await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z'));
|
||||
const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts');
|
||||
await writeFile(fresh, 'fresh generated output');
|
||||
await certifyGeneratedState(root);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||
});
|
||||
Reference in New Issue
Block a user