wip(sync): merge main into next with combined resolutions
This commit is contained in:
@@ -189,15 +189,24 @@ bash tools/install.sh --dev # Contributor lane: source build at --ref/ma
|
||||
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
|
||||
```
|
||||
|
||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||
|
||||
## Universal Skills
|
||||
|
||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories.
|
||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`. Install, wizard finalization, and `mosaic update` automatically reconcile every canonical skill into Claude Code's `~/.claude/skills/` directory.
|
||||
|
||||
```bash
|
||||
mosaic sync # Full sync (clone + link)
|
||||
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
|
||||
mosaic sync # Full canonical catalog sync
|
||||
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
|
||||
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
||||
mosaic skill register <name> # Register or repair one canonical Claude link
|
||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||
```
|
||||
|
||||
Skill names are direct children using `[A-Za-z0-9][A-Za-z0-9._-]*`, not paths. Registration rejects traversal/control characters and never replaces foreign files, directories, or symlinks; unregister removes only links that point inside the canonical Mosaic skill root. After registering during a running Claude Code session, use `/reload-skills` or start a new session.
|
||||
|
||||
M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosaic root through its launcher configuration. Codex parity remains follow-up scope and continues to use the existing full skill-sync linker.
|
||||
|
||||
## Health Audit
|
||||
|
||||
```bash
|
||||
|
||||
@@ -5,20 +5,20 @@ Tool suites live at `~/.config/mosaic/tools/<suite>/`. This is the index only.
|
||||
read it (or the relevant service guide) when your task actually touches that service.
|
||||
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
||||
|
||||
## ⚡ Most-used fleet tools (reach for these FIRST — don't hand-roll)
|
||||
## Most-used fleet tools (reach for these first)
|
||||
|
||||
You are a Mosaic fleet agent. These cover the highest-frequency cross-agent and git-provider
|
||||
tasks — use them before improvising with raw `tmux send-keys`, raw `tea`/`gh`/`glab`, or `curl`.
|
||||
<!-- fleet-comms-contract: 1 -->
|
||||
|
||||
**1. Message another agent** → `tools/tmux/agent-send.sh` (NOT raw `tmux send-keys`):
|
||||
You are a Mosaic fleet agent. Use the runtime-composed **Fleet Comms — authoritative exact targets**
|
||||
section for inter-agent messaging. It renders your authoritative local host, exact agent/session, resolved
|
||||
tmux socket, installed helper path, generation, and one executable command per known peer.
|
||||
|
||||
```bash
|
||||
tools/tmux/agent-send.sh -s <target-session> -m "message" # or -f <file> to send a file's contents
|
||||
```
|
||||
Select only a peer row rendered for your exact roster identity. Never invent, substitute, or fuzzy-match
|
||||
a host, session, socket, SSH destination, or helper path. If a peer is absent, stop and run the exact
|
||||
self-scoped discovery command shown in that composed section; report the peer as unknown if it remains
|
||||
absent. Do not use raw `tmux send-keys` for fleet messaging.
|
||||
|
||||
The coordinator session is `mos-claude` — send status, findings, and questions there.
|
||||
|
||||
**2. Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
|
||||
**Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
|
||||
|
||||
```bash
|
||||
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://mosaicstack.dev/schemas/wake-watch-list.schema.json",
|
||||
"title": "Mosaic Wake Watch-List",
|
||||
"description": "Declarative watch-list for the wake/heartbeat detector (EPIC #892). The SCHEMA is framework-owned; the VALUES are operator-supplied (repos, board files, lane anchors, per-class SLOs). This is the W2 schema contract only — the detector (W4) and digest renderer (W3) consume it. Per CONVERGED-DESIGN §1.4: 'operator repo; schema is framework, values are operator.'",
|
||||
"type": "object",
|
||||
"required": ["schema_version", "watches"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Watch-list schema version. The wake component's manifest.txt declares the supported range (schema_min/schema_max, Gate B); a watch-list outside that range is rejected by the component, not silently coerced."
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Optional operator label for the host this watch-list serves. Per-host single-instance detector (§1.1). Operator-supplied; no semantic meaning to the schema."
|
||||
},
|
||||
"repos": {
|
||||
"type": "array",
|
||||
"description": "Git repositories to watch. Source SHAs are descriptors, not the cursor (§2.4).",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Operator-chosen stable identifier for this repo watch."
|
||||
},
|
||||
"remote": {
|
||||
"type": "string",
|
||||
"description": "Remote/clone locator (operator-supplied). No credentials inline; secrets are by-name via load_credentials."
|
||||
},
|
||||
"branches": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Branch refs to track. Empty => default branch."
|
||||
},
|
||||
"class": { "$ref": "#/$defs/class" },
|
||||
"slo": { "$ref": "#/$defs/slo_ref" },
|
||||
"aba_sensitive": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If true, this source needs an event-stream/webhook rather than poll-only (intra-poll ABA mitigation, §2.4 / gate G5). Poll-only remains a mitigation, not elimination."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"board_files": {
|
||||
"type": "array",
|
||||
"description": "Board / decision files whose edits must be caught (repo-section/anchor-scoped hashing, §1.1). Human-decision file edits, not just API-visible state.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "path"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"repo": {
|
||||
"type": "string",
|
||||
"description": "Optional reference to a repos[].id this file lives in."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path (operator-supplied). Locators are hard: repo/issue#/SHA/file:anchor (§2.1)."
|
||||
},
|
||||
"class": { "$ref": "#/$defs/class" },
|
||||
"slo": { "$ref": "#/$defs/slo_ref" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"lane_anchors": {
|
||||
"type": "array",
|
||||
"description": "In-file anchors (headings/markers) scoping a lane's obligations, so a file edit outside the lane's anchor does not wake it.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "anchor"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"board_file": {
|
||||
"type": "string",
|
||||
"description": "Optional reference to a board_files[].id this anchor lives in."
|
||||
},
|
||||
"anchor": {
|
||||
"type": "string",
|
||||
"description": "Anchor text/marker delimiting the lane's section within the file."
|
||||
},
|
||||
"class": { "$ref": "#/$defs/class" },
|
||||
"slo": { "$ref": "#/$defs/slo_ref" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"slos": {
|
||||
"type": "object",
|
||||
"description": "Named per-class urgency SLO tiers. SYMBOLIC — the operator sets concrete durations; the schema only fixes the shape and the class ordering intent (§4: security/lease/CI = tight; board = tens of minutes; routine = hours). No numeric parameters are baked into the framework.",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"class": { "$ref": "#/$defs/class" },
|
||||
"fallback_bound": {
|
||||
"type": "string",
|
||||
"description": "Operator-supplied duration (e.g. '5m', '30m', '4h'). Symbolic tier is set by the operator, not the framework."
|
||||
},
|
||||
"fallback_cadence": {
|
||||
"type": "string",
|
||||
"description": "OPTIONAL, additive (schema_version 1, backward-compatible — omitting it is valid). The per-class cadence bound for the framework-shipped canon FALLBACK WAKE (F7 replacement-before-retirement, EPIC #892): the low-frequency SAFETY-wake timer (mosaic-wake-fallback.timer) that fires the canon drain INDEPENDENT of the event-driven detector, so a stalled detector cannot silently starve delivery. The A10 installer reads this per-class value and writes it as the fallback timer's OnUnitActiveSec via the blank-reset drop-in (exactly one effective OnUnitActiveUSec). SYMBOLIC — an operator-supplied duration (e.g. '30m', '1h', '4h'); the framework bakes in no numeric. Config, not code. Should be no tighter than this tier's `fallback_bound` (the safety wake is a floor, never the primary mechanism)."
|
||||
},
|
||||
"quiet_hours_may_suppress": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "If true, quiet-hours may suppress the cold fallback for this tier. MUST remain false for actionable/critical classes (§3: quiet-hours never gate an actionable/critical class)."
|
||||
},
|
||||
"measure_to": {
|
||||
"type": "string",
|
||||
"enum": ["consumed", "qualified-action-or-handoff"],
|
||||
"description": "Terminal the SLO is measured to (§4/G8): CONSUMED measures reading; qualified-action-or-handoff measures doing. Actionable/critical classes measure to the action terminal."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"watches": {
|
||||
"type": "array",
|
||||
"description": "The declared source-coverage inventory: a lane-by-lane list of every operational source the lane depends on, so an omitted source cannot make the retirement vector pass vacuously (§4/G3 parity inventory). Each entry references a source declared above by kind+id.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["lane", "sources"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"lane": {
|
||||
"type": "string",
|
||||
"description": "Operator lane identifier this watch serves."
|
||||
},
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["kind", "id"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["repo", "board_file", "lane_anchor"],
|
||||
"description": "Which top-level collection the id refers to."
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Reference to repos[].id / board_files[].id / lane_anchors[].id."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"class": {
|
||||
"type": "string",
|
||||
"enum": ["digest", "actionable", "human", "terminal-log", "reaction"],
|
||||
"description": "Wake class (§2.3). Only `digest` coalesces (cumulative-state replace); actionable/human APPEND. ALL classes are durable. Absent class => the consumer treats it as `actionable` (fail-safe)."
|
||||
},
|
||||
"slo_ref": {
|
||||
"type": "string",
|
||||
"description": "Name of an entry in the top-level `slos` map to apply to this source."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,39 @@ package, normally at:
|
||||
```
|
||||
|
||||
The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
|
||||
default tmux server.
|
||||
default tmux server. The roster is the desired-state authority; generated environment files are
|
||||
rebuildable projections, never a second source of configuration.
|
||||
|
||||
## Examples
|
||||
|
||||
- `examples/minimal.yaml` starts one local canary slot.
|
||||
- `examples/local-canary.yaml` starts a small generic dogfood fleet.
|
||||
- `examples/operator-interaction.yaml` is an example Pi operator-interaction
|
||||
service; replace its example agent name before provisioning.
|
||||
|
||||
## Operator interaction service
|
||||
|
||||
`services/operator-interaction.yaml` pins the Pi runtime, GPT-5.6 Sol model,
|
||||
high reasoning, and the `operator-interaction` tool policy. The agent identity
|
||||
is provisioning data: choose a roster name, generate its per-agent environment
|
||||
file, then start the matching generic systemd instance. The service fails before
|
||||
launch if the configured identity does not match the instance or any pinned
|
||||
policy field drifts.
|
||||
|
||||
The installed `tools/fleet/print-interaction-effective-policy.sh` prints only
|
||||
the resolved name, runtime, model, reasoning, and tool policy. It never reads
|
||||
or prints credential variables.
|
||||
|
||||
## Generated agent environment boundary
|
||||
|
||||
`mosaic fleet install` writes a private deterministic projection at
|
||||
`~/.config/mosaic/fleet/agents/<agent>.env.generated`. It may relocate only approved local machine
|
||||
data to `<agent>.env.local`; generated keys, arbitrary commands, secret-like keys, duplicate keys,
|
||||
unknown keys, and unsafe permissions fail before a tmux session is created. Legacy `.env` input is
|
||||
regenerated, relocated, or quarantined and is not a launch authority.
|
||||
|
||||
See [`docs/fleet/reference/generated-env-boundary.md`](../../../../docs/fleet/reference/generated-env-boundary.md)
|
||||
for allowed local keys and the USC downstream interface evidence.
|
||||
|
||||
Initialize a roster:
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Example instance only. Replace `Tess` with the chosen provisioned identity.
|
||||
version: 1
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~/src
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: Tess
|
||||
runtime: pi
|
||||
class: operator-interaction
|
||||
model_hint: openai/gpt-5.6-sol
|
||||
reasoning_level: high
|
||||
tool_policy: operator-interaction
|
||||
persistent_persona: true
|
||||
@@ -12,19 +12,22 @@ on demand. Engineering personas have no explicit `domain:` marker (they are the
|
||||
implicit `engineering` domain); cross-domain personas carry a `domain:` key in
|
||||
their intro so tooling can group them.
|
||||
|
||||
> This file is an index only — no code imports it. To add a persona, drop a new
|
||||
> `*.md` next to the others (mirroring the existing structure) and add a row here.
|
||||
> This file is an index, not an authority source. The fleet persona resolver reads
|
||||
> its rows for discovery compatibility, then requires a readable `*.md` contract;
|
||||
> authority is derived from canonical class metadata in code, never from this prose.
|
||||
|
||||
## engineering
|
||||
|
||||
| Persona | Purpose |
|
||||
| --------------- | ------------------------------------------------------------------------------ |
|
||||
| orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work |
|
||||
| team-leader | Coordinates only orchestrator-leased capacity for one bounded project |
|
||||
| board | Multi-lens deliberation panel; owns the mission's direction, not its execution |
|
||||
| planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG |
|
||||
| decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges |
|
||||
| code | Primary executor — one card, one branch, one PR to green CI |
|
||||
| review | Correctness reviewer — judges an open PR on correctness, scope, and coverage |
|
||||
| validator | Independent final evidence certificate; never approves-to-land or merges |
|
||||
| security-review | Second line of review — secrets, auth, and forbidden-path safety |
|
||||
| site-tester | Runtime verifier — runs the change and checks behavior vs. acceptance criteria |
|
||||
| documentation | Prose maintainer — keeps human-facing docs and projections in sync |
|
||||
@@ -33,6 +36,7 @@ their intro so tooling can group them.
|
||||
| operator | Escalation and control surface — owns exceptions and the fleet pause switch |
|
||||
| session-review | Post-task retrospective — turns finished work into improvement signals |
|
||||
| enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness |
|
||||
| interaction | Operator request/status surface; routes orchestration and merge decisions |
|
||||
|
||||
## executive
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Interaction — fleet role definition
|
||||
|
||||
The **interaction** role (`class: interaction`) is the operator-facing request and status surface for Mosaic.
|
||||
|
||||
## Mandate
|
||||
|
||||
1. Receive operator requests and present observable fleet or runtime status.
|
||||
2. Route orchestration requests to the orchestrator and merge decisions to the merge-gate.
|
||||
3. Report supported actions and their outcomes without claiming another role's authority.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Request/status only; it does not orchestrate, issue leases, approve-to-land, or merge.
|
||||
- It does not mutate roster configuration, role authority, or credentials.
|
||||
- A configured instance name such as Tess is display data, never a class or authority source.
|
||||
- `operator-interaction` remains a compatibility alias for this canonical class.
|
||||
@@ -13,7 +13,14 @@ 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). These two scripts are the _only_ sanctioned merge path.
|
||||
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.
|
||||
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`.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Operator Interaction — fleet role definition
|
||||
|
||||
The **operator-interaction** role is the authorized human interaction plane for
|
||||
Mosaic. It presents runtime and fleet state, mediates approved actions, and
|
||||
hands coding or general orchestration work to the orchestrator.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- It does not claim orchestrator-owned coding or general orchestration work.
|
||||
- It exposes only the configured, observable tool policy.
|
||||
- It does not receive or surface credentials in its effective policy.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Team leader — fleet role definition
|
||||
|
||||
The **team-leader** (`class: team-leader`) coordinates a bounded project team using only capacity granted by an orchestrator-issued lease.
|
||||
|
||||
## Mandate
|
||||
|
||||
1. Direct the leased coder, reviewer, and validator capacity for the assigned project scope.
|
||||
2. Track delivery status and return results or blockers to the orchestrator.
|
||||
3. Stop using capacity when the lease or assignment ends.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Leased capacity only; this role does not issue or expand its own lease.
|
||||
- It cannot change fleet roster membership, role authority, fleet configuration, or credentials.
|
||||
- It cannot approve-to-land or merge.
|
||||
- It does not displace the orchestrator's topology and lease authority.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Validator — fleet role definition
|
||||
|
||||
The **validator** (`class: validator`) is the independent final evidence seat. It examines the accepted requirements, test evidence, review record, and candidate head and may issue a validation certificate for that exact evidence set.
|
||||
|
||||
## Mandate
|
||||
|
||||
1. Validate acceptance evidence independently from the implementation author.
|
||||
2. Issue or withhold a final validation certificate for the reviewed candidate.
|
||||
3. Report missing, stale, or contradictory evidence without altering it.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- **Certificate only:** the validator does not approve-to-land or merge.
|
||||
- It does not replace correctness or security review.
|
||||
- It does not write product code, mutate the roster, issue leases, or access credentials.
|
||||
- A configured instance name such as Ultron is display data, never a class or authority source.
|
||||
@@ -75,6 +75,14 @@
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||
},
|
||||
"alias": {
|
||||
"description": "Optional operator-defined display name for the agent.",
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"description": "Optional agent runtime provider identifier such as openai-codex.",
|
||||
"type": "string"
|
||||
},
|
||||
"runtime": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -86,11 +94,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"ssh": {
|
||||
"description": "SSH target (user@host) for a cross-host peer, so onboarding renders the `agent-send.sh -H <user@host>` form. Optional; only needed for agents on a different host than the fleet.",
|
||||
"description": "Explicit SSH target (normally user@host) for a cross-host inventory peer. Exact comms rendering requires this whenever the peer's resolved host differs from the current agent's host; the host value is never substituted as an SSH destination.",
|
||||
"type": "string"
|
||||
},
|
||||
"socket": {
|
||||
"description": "tmux socket the agent's session runs on. Onboarding renders `-L <socket>` when set; absent = the default socket (no `-L`). Must match the LIVE socket, not blindly inherit the roster's tmux.socket_name.",
|
||||
"description": "Optional compatibility declaration of the fleet-wide tmux socket. When present it must exactly equal tmux.socket_name; independent per-agent sockets are rejected because the local fleet runtime provisions every session on the fleet-wide socket.",
|
||||
"type": "string"
|
||||
},
|
||||
"working_directory": {
|
||||
@@ -105,6 +113,18 @@
|
||||
"modelHint": {
|
||||
"type": "string"
|
||||
},
|
||||
"reasoning_level": {
|
||||
"type": "string"
|
||||
},
|
||||
"reasoningLevel": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_policy": {
|
||||
"type": "string"
|
||||
},
|
||||
"toolPolicy": {
|
||||
"type": "string"
|
||||
},
|
||||
"persistent_persona": {
|
||||
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
|
||||
},
|
||||
@@ -130,29 +150,67 @@
|
||||
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": ["tmux", "discord", "matrix"]
|
||||
},
|
||||
"matrix": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["homeserver_url", "user_id", "room_id"],
|
||||
"properties": {
|
||||
"homeserver_url": { "type": "string" },
|
||||
"user_id": { "type": "string" },
|
||||
"room_id": { "type": "string" }
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": { "kind": { "const": "tmux" } },
|
||||
"required": ["kind"],
|
||||
"not": {
|
||||
"anyOf": [{ "required": ["discord"] }, { "required": ["matrix"] }]
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["channel_id"],
|
||||
{
|
||||
"properties": {
|
||||
"channel_id": { "type": "string" }
|
||||
}
|
||||
"kind": { "const": "discord" },
|
||||
"discord": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["channel_id"],
|
||||
"properties": {
|
||||
"channel_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["kind", "discord"],
|
||||
"not": { "required": ["matrix"] }
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"kind": { "const": "matrix" },
|
||||
"matrix": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["homeserver_url", "user_id", "room_id"],
|
||||
"properties": {
|
||||
"homeserver_url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"room_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["kind", "matrix"],
|
||||
"not": { "required": ["discord"] }
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"kind": { "enum": ["tmux", "discord", "matrix"] },
|
||||
"matrix": { "type": "object" },
|
||||
"discord": { "type": "object" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Generic service policy. Provisioning supplies the agent name as data.
|
||||
runtime: pi
|
||||
model: openai/gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: operator-interaction
|
||||
@@ -0,0 +1,90 @@
|
||||
# Mosaic framework path-ownership manifest — SSOT for the updater.
|
||||
#
|
||||
# This single file is the source of truth consumed by BOTH the bash installer
|
||||
# (packages/mosaic/framework/install.sh) and the TypeScript config adapter
|
||||
# (packages/mosaic/src/config/file-adapter.ts). A parity test asserts both
|
||||
# paths resolve the same ownership from this file, so the two can never drift
|
||||
# (the failure mode that #631 patched by hand in two places).
|
||||
#
|
||||
# Format: one glob per line, relative to the mosaic home (~/.config/mosaic).
|
||||
# - Lines starting with '#' and blank lines are ignored.
|
||||
# - '[framework]' / '[operator]' switch the active section.
|
||||
# - '**' matches any depth; '*' matches within a single path segment.
|
||||
#
|
||||
# Ownership resolution for a path P (deny-wins / fail-safe):
|
||||
# 1. P matches an [operator] glob -> operator-owned.
|
||||
# 2. else P matches a [framework] glob -> framework-owned.
|
||||
# 3. else (matches neither) -> OPERATOR-OWNED BY DEFAULT.
|
||||
#
|
||||
# Rule 3 is the root-cause fix for #791: a path the manifest authors never
|
||||
# anticipated is protected because UNKNOWN defaults to operator. The updater
|
||||
# may only ever create/overwrite framework-owned paths, and may only prune a
|
||||
# framework-owned path that lives inside a shipped framework subtree and is
|
||||
# absent from the current framework source (a genuinely retired file).
|
||||
# Operator-owned and unknown paths are structurally unreachable by pruning.
|
||||
|
||||
[framework]
|
||||
# Top-level framework contract files (also reconciled from defaults/ on upgrade).
|
||||
CONSTITUTION.md
|
||||
AGENTS.md
|
||||
STANDARDS.md
|
||||
# Shipped framework subtrees — pruning is scoped to these roots.
|
||||
adapters/**
|
||||
constitution/**
|
||||
CONTRIBUTING.md
|
||||
defaults/**
|
||||
examples/**
|
||||
guides/**
|
||||
# Shipped framework subtree — canonical skills are upgrade-reconciled.
|
||||
skills/**
|
||||
install.sh
|
||||
install.ps1
|
||||
LICENSE
|
||||
profiles/**
|
||||
runtime/**
|
||||
systemd/**
|
||||
templates/**
|
||||
tools/**
|
||||
# Fleet: only the framework-seeded fleet subtrees are framework-owned.
|
||||
fleet/README.md
|
||||
fleet/examples/**
|
||||
fleet/profiles/**
|
||||
fleet/roles/**
|
||||
fleet/roster.schema.json
|
||||
fleet/services/**
|
||||
# The manifest itself is framework-owned.
|
||||
framework-manifest.txt
|
||||
|
||||
[operator]
|
||||
# Identity / user-seeded contract files — generated by the wizard or seeded
|
||||
# once from defaults/, then owned by the operator. Never overwritten on upgrade.
|
||||
SOUL.md
|
||||
USER.md
|
||||
TOOLS.md
|
||||
# Local overlays (tighten-only) authored by the operator.
|
||||
*.local.md
|
||||
# Operator-owned trees the updater must never write over or prune.
|
||||
agents/**
|
||||
policy/**
|
||||
memory/**
|
||||
sources/**
|
||||
credentials/**
|
||||
# Operator-authored/customized skills live separately from canonical skills/ and
|
||||
# must remain structurally unprunable even as skills/** is framework-owned.
|
||||
skills-local/**
|
||||
# Secret-bearing operator file INSIDE the framework-owned tools/ subtree.
|
||||
# Listed explicitly so the deny-wins rule carves it out of tools/**.
|
||||
tools/_lib/credentials.json
|
||||
# Operator-owned fleet state (roster SSOT, per-agent env, heartbeats, backlog,
|
||||
# persona overrides). Losing these silently downgrades a running fleet (#791).
|
||||
fleet/roster.yaml
|
||||
fleet/roster.json
|
||||
fleet/agents/**
|
||||
# Runtime state, incl. the #797 Runtime Session Ledger at fleet/run/sessions/
|
||||
# (events.ndjson journal + ledger.json projection). This carve-out is the
|
||||
# mechanism that makes the ledger upgrade-safe: an upgrade that wiped it would
|
||||
# defeat its reason to exist. The HARD GATE (test-upgrade-manifest-guard.sh)
|
||||
# proves a populated ledger survives byte-identical + mtime-unchanged.
|
||||
fleet/run/**
|
||||
fleet/backlog/**
|
||||
fleet/roles.local/**
|
||||
@@ -15,6 +15,22 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||
|
||||
## Agent Host Prerequisites
|
||||
|
||||
Agent hosts must provide the Python runtime shape that runtime agents and
|
||||
Mosaic automation assume is present.
|
||||
|
||||
For Debian/Ubuntu hosts:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
# #561: bare python invocations from agents must resolve.
|
||||
sudo apt-get install -y python3 python-is-python3
|
||||
```
|
||||
|
||||
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
|
||||
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -868,6 +868,38 @@ 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.
|
||||
@@ -893,14 +925,16 @@ 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 -B main
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
||||
~/.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>
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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` (or PowerShell equivalent).
|
||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (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 to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
||||
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>`.
|
||||
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 main`
|
||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
||||
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>`
|
||||
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.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
When spawning workers, include skill loading in the kickstart:
|
||||
|
||||
```bash
|
||||
claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."codex exec "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."
|
||||
mosaic claude -p "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."codex exec "Read ~/.config/mosaic/skills/nestjs-best-practices/SKILL.md then implement..."
|
||||
```
|
||||
|
||||
#### **MANDATORY**
|
||||
@@ -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 main`
|
||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||
- 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`
|
||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||
- 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 main` 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 {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||
|
||||
## Git Scripts
|
||||
|
||||
@@ -638,8 +638,9 @@ 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`
|
||||
- `~/.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`
|
||||
- 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/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
||||
|
||||
|
||||
@@ -23,10 +23,12 @@ 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)
|
||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||
~/.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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Wake Doctrine
|
||||
|
||||
This is the canonical fleet wake/heartbeat doctrine, extracted verbatim from the ratified
|
||||
converged wake/heartbeat design (`docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md`). It
|
||||
governs when agents wake and how a wake is delivered, consumed, and retired.
|
||||
|
||||
**Wake only on a real, un-consumed, lane-relevant obligation.** Fixed-interval heartbeats are
|
||||
forbidden as the primary wake mechanism; they survive only as a **per-class fallback cadence**
|
||||
bounded by urgency SLO, never as the steady state.
|
||||
|
||||
**A digest is cumulative state since the last CONSUMED ack**, not an event delta. It is
|
||||
self-orienting (who / lane / board-head) and decides the no-op case with **zero tool calls**.
|
||||
Actionable facts are **claims-to-verify** carrying a **hard locator** (repo/issue#/SHA/file);
|
||||
self-sufficiency never exempts a consequential action from its live gate.
|
||||
|
||||
**Consumption is a consumer act, not a delivery act.** Split RECEIVED (delivery; `wake_id`-deduped)
|
||||
from CONSUMED (durable capture of a contiguous prefix). Never ack-then-crash-before-capture. Acks
|
||||
are local-write-only and cumulative; a turn never blocks on the network to ack.
|
||||
|
||||
**Durability is unconditional; coalescing is optional.** Every delivered class is durably stored
|
||||
and acked; only machine `digest` wakes coalesce. A parked or absent pane must never lose a human
|
||||
or peer message.
|
||||
|
||||
**Park is two-phase:** flush-and-checkpoint (recording the CONSUMED cursor) _before_ `/clear`.
|
||||
|
||||
**Liveness is independent of work-triggering:** an off-host dead-man beacon, alarming on absence —
|
||||
never a same-host sibling, never a pane scrape.
|
||||
|
||||
**Retire the old net LAST:** run new alongside old, compare ledgers, and cut over only when the
|
||||
per-host safety vector (no-op-rate ↓ AND canary-FN=0 AND source-parity-inventory-complete AND
|
||||
reconcile=0 AND p95 event→CONSUMED≤SLO AND p95 event→qualified-action≤SLO) passes.
|
||||
@@ -1,5 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# -E (errtrace): the ERR trap must propagate INTO functions and command
|
||||
# substitutions. Without it the `trap restore_snapshot ERR` set below is dead
|
||||
# code for any failure inside sync_framework_keep() (its whole body runs in a
|
||||
# function) — a mid-sync failure would abort with a half-written target and NO
|
||||
# rollback (#791 B1). Keep -E first so every later function inherits the trap.
|
||||
set -Eeuo pipefail
|
||||
|
||||
# ─── Mosaic Framework Installer ──────────────────────────────────────────────
|
||||
#
|
||||
@@ -13,38 +18,55 @@ set -euo pipefail
|
||||
# MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt)
|
||||
# MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check
|
||||
# MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync
|
||||
#
|
||||
# Flags (CLI args, NOT environment variables — see #869 Point-1 C2):
|
||||
# --allow-inactive-enforcement Explicit, per-invocation opt-out that lets the
|
||||
# lease-enforcement hooks (mutator-gate.py,
|
||||
# receipt-observer-client.py) be wired into
|
||||
# ~/.claude/settings.json even when this host
|
||||
# cannot confirm it can ACTIVATE them. Loud on
|
||||
# use (see mosaic-link-runtime-assets). Default
|
||||
# (flag absent) is fail-loud: the enforcement
|
||||
# hooks are NOT wired and the framework's
|
||||
# runtime-asset-link step reports a failure.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
|
||||
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
|
||||
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
|
||||
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
|
||||
# User-created content in these paths survives rsync --delete.
|
||||
#
|
||||
# fleet/* — the framework SEEDS fleet/examples, fleet/roles, fleet/profiles, and
|
||||
# fleet/roster.schema.json (synced normally — every fleet/roles/*.md role contract
|
||||
# and fleet/profiles/*.yaml system-type profile lands automatically via this sync,
|
||||
# so no per-file entry is needed; the preserved "fleet/*.yaml" glob is anchored to
|
||||
# the top level only and does NOT shadow fleet/profiles/*.yaml). The user's
|
||||
# own fleet files MUST
|
||||
# survive `mosaic update` (which runs this sync automatically): the active
|
||||
# roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env
|
||||
# (`fleet/agents/`), heartbeat run dir (`fleet/run/`), and the Mosaic-native
|
||||
# backlog-of-record store (`fleet/backlog/` — embedded PGlite data dir; see
|
||||
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
|
||||
# wipes the operator's fleet AND their backlog. Glob entries are honored by
|
||||
# both the rsync path (`--exclude`) and the glob-aware cp fallback below.
|
||||
#
|
||||
# fleet/roles.local — the persona OVERRIDE layer (H4). Baseline personas in
|
||||
# fleet/roles/ are reseeded normally on every update (delivering new baseline
|
||||
# personas), so any local edit there would be clobbered. User customizations
|
||||
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
|
||||
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
|
||||
# packages/mosaic/src/commands/fleet-personas.ts).
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
|
||||
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
|
||||
# never an environment variable — so this opt-out can never sit silently
|
||||
# inherited in a shell profile. See #869 Point-1 C2.
|
||||
ALLOW_INACTIVE_ENFORCEMENT=0
|
||||
# Component-scoped install (#892 W7): `install.sh --component <name>` runs an
|
||||
# additive, self-contained component installer and EXITS — it never enters the
|
||||
# full-framework sync below and never modifies framework-manifest ownership
|
||||
# behavior (#869: the diff is ADDITIVE). Parsed as a two-token flag here.
|
||||
COMPONENT=""
|
||||
_prev_arg=""
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
--allow-inactive-enforcement) ALLOW_INACTIVE_ENFORCEMENT=1 ;;
|
||||
--component=*) COMPONENT="${_arg#--component=}" ;;
|
||||
esac
|
||||
[[ "$_prev_arg" == "--component" ]] && COMPONENT="$_arg"
|
||||
_prev_arg="$_arg"
|
||||
done
|
||||
|
||||
# Shared framework path-ownership manifest reader (#791). Parity with
|
||||
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
|
||||
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
|
||||
# shellcheck source=tools/_lib/manifest.sh
|
||||
source "$SOURCE_DIR/tools/_lib/manifest.sh"
|
||||
|
||||
# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
|
||||
# denylist. It is derived from the shared framework-manifest.txt (#791): the
|
||||
# updater only ever creates/overwrites framework-owned paths and only prunes a
|
||||
# retired framework file inside a shipped framework subtree. Everything else —
|
||||
# every operator file, and every path the manifest never anticipated — is
|
||||
# operator-owned by default (fail-safe) and is never written or deleted. See
|
||||
# sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts.
|
||||
|
||||
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
||||
# user must not edit them; a divergent copy is backed up once before overwrite).
|
||||
@@ -75,17 +97,267 @@ step() { echo -e "\n${BOLD}$1${RESET}"; }
|
||||
SNAPSHOT_DIR=""
|
||||
make_snapshot() {
|
||||
is_existing_install || return 0
|
||||
# mktemp -d creates the dir 0700 — the snapshot (which mirrors operator config,
|
||||
# possibly including secrets) is never world-readable.
|
||||
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
|
||||
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
|
||||
# The snapshot MUST be complete: restore rebuilds the target from it, so a
|
||||
# partial capture (unreadable file, disk-full, I/O error) would silently
|
||||
# discard whatever it missed. If cp -a cannot copy the whole tree, abort NOW —
|
||||
# before the restore trap is armed and before anything is mutated. Fail closed
|
||||
# rather than proceed with a snapshot we cannot trust (#791 blocker-2).
|
||||
if ! cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/"; then
|
||||
fail "Could not capture a complete pre-upgrade snapshot of $TARGET_DIR — aborting before any changes were made (fail-closed)."
|
||||
rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
restore_snapshot() {
|
||||
# Disarm the trap first: restore runs under `set -e`, and a non-zero step
|
||||
# inside it must not re-enter this handler (errtrace makes ERR fire in
|
||||
# functions now). One restore attempt, then let the script exit non-zero.
|
||||
trap - ERR INT TERM
|
||||
[[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
|
||||
fail "Install interrupted/failed — restoring previous state from snapshot"
|
||||
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
|
||||
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
|
||||
# Reset the target before rebuilding from the snapshot — but CHECK it. Under
|
||||
# `set -e` (trap already disarmed) a bare `rm -rf; mkdir -p` that fails would
|
||||
# exit the whole script immediately, after `rm` may have deleted part of the
|
||||
# target, WITHOUT ever printing the recovery pointer below — the operator would
|
||||
# be left with a half-removed target and no idea the snapshot survives in /tmp.
|
||||
# Test the reset explicitly (like the cp -a below), and on failure keep the
|
||||
# snapshot and tell the operator where it is (#791 blocker-D2).
|
||||
if ! rm -rf "$TARGET_DIR" || ! mkdir -p "$TARGET_DIR"; then
|
||||
fail "Snapshot restore could not reset $TARGET_DIR. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
|
||||
return 1
|
||||
fi
|
||||
# Surface an incomplete restore instead of swallowing it: the snapshot is the
|
||||
# last good copy, so if cp cannot fully rebuild the target we must NOT delete
|
||||
# the snapshot — point the operator at it for manual recovery (#791 blocker-2).
|
||||
if ! cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/"; then
|
||||
fail "Snapshot restore did not complete cleanly. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
|
||||
|
||||
# ─── durable operator-config snapshot (#791 PR2) ─────────────────────────────
|
||||
# A SECOND, independent safety layer, distinct from SNAPSHOT_DIR above:
|
||||
# • SNAPSHOT_DIR is ephemeral (/tmp, deleted on success) and mirrors the WHOLE
|
||||
# target for CRASH rollback if the sync aborts mid-write.
|
||||
# • DURABLE_SNAPSHOT_DIR is RETAINED, holds only the operator-owned surface, and
|
||||
# lives OUTSIDE the framework tree and any repo. It exists for the failure the
|
||||
# crash-rollback cannot see: a sync that finishes "successfully" yet a
|
||||
# manifest/logic bug let it modify an operator file. verify_operator_surface()
|
||||
# (post-sync) heals from it; `mosaic restore` recovers from it days later.
|
||||
# Path convention is mirrored in packages/mosaic/src/commands/restore.ts — keep
|
||||
# the two in sync (there is no shared code across the bash/TS boundary).
|
||||
DURABLE_SNAPSHOT_DIR=""
|
||||
backup_root() { printf '%s/mosaic/backups' "${XDG_STATE_HOME:-$HOME/.local/state}"; }
|
||||
|
||||
# Relative paths that a migration INTENTIONALLY removes from the target (e.g. the
|
||||
# legacy bin/ tree). Such a path is operator-classified by the manifest (unknown⇒
|
||||
# operator), so the durable snapshot captures it — but its post-migration absence
|
||||
# is correct, NOT a manifest bug. run_migrations() records each removal here so
|
||||
# verify_operator_surface() does not "heal" it back and silently undo the
|
||||
# migration (which would then be skipped forever once the version is stamped).
|
||||
MIGRATION_REMOVED_PATHS=()
|
||||
|
||||
# True (0) if $1 (a path relative to TARGET_DIR) equals or lives under a path a
|
||||
# migration deliberately removed this run.
|
||||
is_migration_removed() {
|
||||
local rel="$1" removed
|
||||
for removed in ${MIGRATION_REMOVED_PATHS[@]+"${MIGRATION_REMOVED_PATHS[@]}"}; do
|
||||
[[ -n "$removed" ]] || continue
|
||||
[[ "$rel" == "$removed" || "$rel" == "$removed"/* ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# True (0) if any parent directory of $1 (relative to TARGET_DIR) is a symlink.
|
||||
# Restoring THROUGH a symlinked ancestor would let cp write snapshot contents —
|
||||
# possibly secrets — outside the target (CWE-59), so the verify net refuses it.
|
||||
has_symlinked_parent() {
|
||||
local rel="$1" dir p seg
|
||||
dir="$(dirname "$rel")"
|
||||
[[ "$dir" == "." ]] && return 1
|
||||
p="$TARGET_DIR"
|
||||
local IFS='/'
|
||||
for seg in $dir; do
|
||||
[[ -n "$seg" ]] || continue
|
||||
p="$p/$seg"
|
||||
[[ -L "$p" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Emit (NUL-delimited, into file $1) the operator-owned relative paths that exist
|
||||
# under TARGET_DIR, classified via the shared manifest (deny-wins; unknown⇒
|
||||
# operator). Returns non-zero if the filesystem walk itself failed — we must
|
||||
# NEVER snapshot from a truncated scan (a `< <(find …)` process substitution
|
||||
# would hide that error; capture-then-check does not — cf. #791 blocker-D1).
|
||||
enumerate_operator_files() {
|
||||
local out="$1" scan abs rel
|
||||
scan="$(mktemp)"
|
||||
if ! find "$TARGET_DIR" -type f -print0 > "$scan"; then
|
||||
rm -f "$scan"
|
||||
return 1 # OP-SCAN-GUARD
|
||||
fi
|
||||
: > "$out"
|
||||
while IFS= read -r -d '' abs; do
|
||||
rel="${abs#"$TARGET_DIR"/}"
|
||||
# Not operator config: version marker and any VCS metadata.
|
||||
case "$rel" in .framework-version|.git|.git/*) continue ;; esac
|
||||
manifest_is_framework "$rel" || printf '%s\0' "$rel" >> "$out"
|
||||
done < "$scan"
|
||||
rm -f "$scan"
|
||||
}
|
||||
|
||||
# Retain only the newest MOSAIC_BACKUP_RETENTION (default 5) snapshots. The
|
||||
# pre-update-<UTC-ts> names sort lexicographically = chronologically, so a
|
||||
# reverse sort is newest-first. Pruning failures are non-fatal (they only leave
|
||||
# extra old backups); the enclosing find's status is still honored, not swallowed.
|
||||
prune_durable_snapshots() {
|
||||
local root keep list d i=0
|
||||
root="$(backup_root)"
|
||||
keep="${MOSAIC_BACKUP_RETENTION:-5}"
|
||||
[[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
|
||||
list="$(mktemp)"
|
||||
if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
|
||||
rm -f "$list"; return 0
|
||||
fi
|
||||
# Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
|
||||
# dependency); if it is somehow unavailable, leave the backups untouched rather
|
||||
# than risk pruning in an undefined order.
|
||||
if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
|
||||
rm -f "$list"; return 0
|
||||
fi
|
||||
while IFS= read -r d; do
|
||||
[[ -n "$d" ]] || continue
|
||||
i=$((i + 1))
|
||||
(( i > keep )) && rm -rf "$d"
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
}
|
||||
|
||||
# Take the durable pre-update snapshot BEFORE any mutation. Fail-OPEN: the durable
|
||||
# snapshot is a recovery bonus on top of the manifest (which already keeps the
|
||||
# sync out of operator paths) and the crash-rollback — so an un-writable backup
|
||||
# location warns and continues rather than blocking the upgrade. Everything it
|
||||
# creates is private (umask 077 + explicit 0700 dirs / 0600 files): the snapshot
|
||||
# mirrors operator config, which may hold secrets, and must never be world-readable.
|
||||
make_durable_snapshot() {
|
||||
is_existing_install || return 0
|
||||
local root ts dir list rel src dst count=0 old_umask
|
||||
root="$(backup_root)"
|
||||
# Fail-open if we cannot even stamp a timestamp: the durable snapshot is a
|
||||
# recovery bonus and must never be the thing that aborts an upgrade.
|
||||
ts="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || true)"
|
||||
if [[ -z "$ts" ]]; then
|
||||
warn "Durable snapshot skipped: no UTC timestamp available (upgrade continues)."
|
||||
return 0
|
||||
fi
|
||||
# umask 077 makes every dir/file the snapshot creates private from birth (it
|
||||
# mirrors operator config, which may hold secrets). It is PROCESS-global, so we
|
||||
# save and restore it around exactly this block — otherwise every later sync
|
||||
# copy and new framework dir would inherit 0600/0700 instead of 0644/0755.
|
||||
old_umask="$(umask)"
|
||||
umask 077
|
||||
if ! mkdir -p "$root"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
|
||||
return 0
|
||||
fi
|
||||
chmod 700 "$root" 2>/dev/null || true
|
||||
dir="$root/pre-update-$ts"
|
||||
if [[ -e "$dir" ]]; then # same-second re-run: disambiguate
|
||||
local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
|
||||
fi
|
||||
if ! mkdir -p "$dir"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: cannot create $dir (upgrade continues)."
|
||||
return 0
|
||||
fi
|
||||
chmod 700 "$dir"
|
||||
list="$(mktemp)"
|
||||
if ! enumerate_operator_files "$list"; then
|
||||
umask "$old_umask"
|
||||
warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
|
||||
rm -f "$list"; rmdir "$dir" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r -d '' rel; do
|
||||
src="$TARGET_DIR/$rel"; dst="$dir/$rel"
|
||||
[[ -f "$src" ]] || continue
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
if ! cp "$src" "$dst"; then
|
||||
warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
|
||||
continue
|
||||
fi
|
||||
chmod 600 "$dst" 2>/dev/null || true
|
||||
count=$((count + 1))
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
# Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
|
||||
find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
|
||||
umask "$old_umask" # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
|
||||
DURABLE_SNAPSHOT_DIR="$dir"
|
||||
ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
|
||||
prune_durable_snapshots
|
||||
}
|
||||
|
||||
# Post-sync safety net: a keep-mode upgrade must NEVER modify an operator file.
|
||||
# Compare every file in the durable snapshot to its current target counterpart;
|
||||
# any that changed (or vanished) was touched by a framework bug — restore it from
|
||||
# the snapshot and warn loudly. This does NOT abort: the framework itself synced
|
||||
# correctly; we only heal the operator collateral. Runs after the restore trap is
|
||||
# disarmed so its corrective copies can't spuriously trip a full rollback, and
|
||||
# every step is guarded so `set -e` cannot exit silently mid-heal (cf. blocker-D2).
|
||||
verify_operator_surface() {
|
||||
[[ -n "$DURABLE_SNAPSHOT_DIR" && -d "$DURABLE_SNAPSHOT_DIR" ]] || return 0
|
||||
local scan snap rel cur healed=0
|
||||
scan="$(mktemp)"
|
||||
if ! find "$DURABLE_SNAPSHOT_DIR" -type f -print0 > "$scan"; then
|
||||
rm -f "$scan"
|
||||
warn "Post-upgrade verify skipped: could not enumerate the pre-update snapshot at $DURABLE_SNAPSHOT_DIR."
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r -d '' snap; do
|
||||
rel="${snap#"$DURABLE_SNAPSHOT_DIR"/}"
|
||||
cur="$TARGET_DIR/$rel"
|
||||
# A migration may legitimately delete an operator-classified path (e.g. legacy
|
||||
# bin/). Its absence is intended — do not heal it back, or the migration is
|
||||
# silently undone and never re-runs once the version is stamped (#791 PR2).
|
||||
is_migration_removed "$rel" && continue # MIGRATION-SKIP-GUARD
|
||||
if [[ ! -e "$cur" ]] || ! cmp -s "$snap" "$cur"; then
|
||||
# Never restore THROUGH a symlink: an operator path swapped for a link would
|
||||
# otherwise let cp write snapshot contents (possibly secrets) outside the
|
||||
# target (CWE-59). Refuse a symlinked parent; drop a symlinked leaf and write
|
||||
# a real file in its place.
|
||||
if has_symlinked_parent "$rel"; then
|
||||
warn "Operator path '$rel' has a symlinked parent under $TARGET_DIR; refusing to restore through it (possible tampering) — recover it manually from $DURABLE_SNAPSHOT_DIR."
|
||||
continue
|
||||
fi
|
||||
[[ -L "$cur" ]] && rm -f "$cur" # SYMLINK-LEAF-GUARD
|
||||
# Guard mkdir too: under set -e (trap already disarmed) a bare failure would
|
||||
# exit the whole installer before the recovery pointer below is emitted.
|
||||
if ! mkdir -p "$(dirname "$cur")"; then
|
||||
warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored (parent dir unavailable) — recover it manually from $DURABLE_SNAPSHOT_DIR."
|
||||
continue
|
||||
fi
|
||||
if cp "$snap" "$cur"; then
|
||||
chmod 600 "$cur" 2>/dev/null || true
|
||||
warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
|
||||
healed=$((healed + 1))
|
||||
else
|
||||
warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored — recover it manually from $DURABLE_SNAPSHOT_DIR."
|
||||
fi
|
||||
fi
|
||||
done < "$scan"
|
||||
rm -f "$scan"
|
||||
if (( healed > 0 )); then
|
||||
warn "$healed operator file(s) were unexpectedly changed by this upgrade and were restored from the pre-update snapshot. A keep-mode upgrade must never modify operator files — this indicates a framework manifest bug; please report it (#791)."
|
||||
fi
|
||||
}
|
||||
|
||||
# Reconcile contract files after sync: framework-owned overwrite (backup-once),
|
||||
# user-seeded seed-if-absent.
|
||||
reconcile_framework_files() {
|
||||
@@ -184,63 +456,105 @@ sync_framework() {
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
|
||||
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
|
||||
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
|
||||
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
rsync_args+=(--exclude "/$path")
|
||||
done
|
||||
fi
|
||||
|
||||
rsync "${rsync_args[@]}" "$SOURCE_DIR/" "$TARGET_DIR/"
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
# The `mosaic update` path. Manifest-driven, never-deleting-outside-framework:
|
||||
# operator config is structurally protected (#791). No rsync --delete here.
|
||||
# The manifest is already loaded+validated in main() BEFORE the snapshot/trap
|
||||
# (a fail-closed manifest must abort without ever restoring over operator
|
||||
# files — see the pre-flight in main, #791 blocker-1).
|
||||
sync_framework_keep
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve
|
||||
# every matching user file (parity with the rsync --exclude path above).
|
||||
local preserve_tmp=""
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
|
||||
local match rel
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
# Unquoted $path lets the glob expand against TARGET_DIR; nullglob makes a
|
||||
# non-matching pattern vanish instead of staying literal.
|
||||
shopt -s nullglob
|
||||
for match in "$TARGET_DIR/"$path; do
|
||||
[[ -e "$match" ]] || continue
|
||||
rel="${match#"$TARGET_DIR/"}"
|
||||
mkdir -p "$preserve_tmp/$(dirname "$rel")"
|
||||
cp -R "$match" "$preserve_tmp/$rel"
|
||||
done
|
||||
shopt -u nullglob
|
||||
done
|
||||
fi
|
||||
# overwrite mode — a full replace, chosen only for a fresh install or when the
|
||||
# operator explicitly asks to replace everything. No operator state to protect.
|
||||
sync_framework_overwrite
|
||||
}
|
||||
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
|
||||
# Enumerate a NUL-delimited file list via `find` into the temp file $1, failing
|
||||
# CLOSED if find errors. We capture to a checked file instead of consuming
|
||||
# `< <(find …)` directly because a process substitution discards the producer's
|
||||
# exit status: an EACCES/I/O failure partway through a scan would truncate the
|
||||
# list yet leave the reading `while` loop exiting 0, so a partial upgrade would
|
||||
# commit and report success and the ERR/restore trap would never fire. Running
|
||||
# find to completion first, then checking its status, turns that silent
|
||||
# truncation into a fail-closed abort that the restore trap can act on (#791
|
||||
# blocker-D1). $1 after the shift is the scan root — named in the error.
|
||||
_scan_or_die() {
|
||||
local out="$1"; shift
|
||||
if ! find "$@" -print0 > "$out"; then
|
||||
fail "Could not enumerate framework files under '$1' — aborting before committing an incomplete sync (fail-closed)."
|
||||
return 1 # D1-GUARD
|
||||
fi
|
||||
}
|
||||
|
||||
# Keep-mode sync: create/refresh framework-owned files and prune only retired
|
||||
# framework files inside shipped framework subtrees. Operator-owned and unknown
|
||||
# paths (fail-safe default) are never written and never deleted — the #791 HARD
|
||||
# GATE. Single code path (no rsync) so it is byte-for-byte parity-testable.
|
||||
sync_framework_keep() {
|
||||
local src="$SOURCE_DIR" dst="$TARGET_DIR" abs rel root list
|
||||
|
||||
# 1) Overlay copy — every framework-owned source file, refreshed only when its
|
||||
# bytes changed (no mtime churn on unchanged files, never on operator files).
|
||||
# The source scan is captured fail-closed (#791 blocker-D1): a find failure
|
||||
# aborts the sync (→ ERR trap → restore) rather than silently truncating it.
|
||||
list="$(mktemp)"
|
||||
_scan_or_die "$list" "$src" -type f || { rm -f "$list"; return 1; }
|
||||
while IFS= read -r -d '' abs; do
|
||||
rel="${abs#"$src"/}"
|
||||
case "$rel" in
|
||||
.git|.git/*|.framework-version|*.pre-constitution.bak) continue ;;
|
||||
esac
|
||||
manifest_is_framework "$rel" || continue
|
||||
if [[ -f "$dst/$rel" ]] && cmp -s "$abs" "$dst/$rel"; then continue; fi
|
||||
[[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}"
|
||||
cp "$abs" "$dst/$rel"
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
|
||||
# 2) Scoped prune — within each shipped framework subtree root, remove
|
||||
# framework-owned target files the current source no longer ships. Operator
|
||||
# carve-outs (e.g. tools/_lib/credentials.json) resolve to operator and are
|
||||
# skipped; unknown paths resolve to operator too — both are unreachable here.
|
||||
# Each subtree scan is captured fail-closed for the same reason as the copy.
|
||||
while IFS= read -r root; do
|
||||
[[ -n "$root" && -d "$dst/$root" ]] || continue
|
||||
list="$(mktemp)"
|
||||
_scan_or_die "$list" "$dst/$root" -type f || { rm -f "$list"; return 1; }
|
||||
while IFS= read -r -d '' abs; do
|
||||
rel="${abs#"$dst"/}"
|
||||
case "$rel" in *.pre-constitution.bak) continue ;; esac
|
||||
[[ -f "$src/$rel" ]] && continue # still shipped
|
||||
manifest_is_framework "$rel" || continue
|
||||
rm -f "$abs"
|
||||
done < "$list"
|
||||
rm -f "$list"
|
||||
# Drop framework dirs left empty by the prune (never touches a dir that still
|
||||
# holds an operator file — those are never emptied). A genuine find failure
|
||||
# (unreadable dir) is surfaced as a warning rather than silently swallowed;
|
||||
# the "directory not empty" races we tolerate are ignored via -delete's own
|
||||
# rc, not by hiding stderr — so a real error is still visible to the operator.
|
||||
if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
|
||||
warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
|
||||
fi
|
||||
done < <(manifest_subtree_roots)
|
||||
}
|
||||
|
||||
# Overwrite-mode sync: full replace. Only reached for a fresh install or an
|
||||
# explicit operator "replace everything" choice, so nothing is preserved.
|
||||
sync_framework_overwrite() {
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -a --delete \
|
||||
--exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \
|
||||
"$SOURCE_DIR/" "$TARGET_DIR/"
|
||||
return
|
||||
fi
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \
|
||||
! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \
|
||||
-exec rm -rf {} +
|
||||
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
|
||||
rm -rf "$TARGET_DIR/.git"
|
||||
|
||||
if [[ -n "$preserve_tmp" ]]; then
|
||||
# Restore by re-globbing the SAME patterns against preserve_tmp, so each
|
||||
# preserved item is restored at its own relative path (e.g. only
|
||||
# fleet/roster.yaml is replaced — the freshly-synced fleet/examples stays).
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
shopt -s nullglob
|
||||
for match in "$preserve_tmp/"$path; do
|
||||
[[ -e "$match" ]] || continue
|
||||
rel="${match#"$preserve_tmp/"}"
|
||||
rm -rf "$TARGET_DIR/$rel"
|
||||
mkdir -p "$TARGET_DIR/$(dirname "$rel")"
|
||||
cp -R "$match" "$TARGET_DIR/$rel"
|
||||
done
|
||||
shopt -u nullglob
|
||||
done
|
||||
rm -rf "$preserve_tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -261,6 +575,10 @@ run_migrations() {
|
||||
# Remove bin/ directory — all executables now live in the npm CLI.
|
||||
# Scripts that were in bin/ are now in tools/_scripts/.
|
||||
if [[ "$from_version" -lt 2 ]]; then
|
||||
# bin/ and the rails symlink are operator-classified by the manifest (unknown⇒
|
||||
# operator) and thus captured in the durable snapshot; record them as
|
||||
# intentional removals so the post-sync verify net does not restore them.
|
||||
MIGRATION_REMOVED_PATHS+=("bin" "rails")
|
||||
if [[ -d "$TARGET_DIR/bin" ]]; then
|
||||
ok "Removing legacy bin/ directory (executables now in npm CLI)"
|
||||
rm -rf "$TARGET_DIR/bin"
|
||||
@@ -296,6 +614,46 @@ run_migrations() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Component-scoped install (#892 W7) — additive early dispatch.
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# `install.sh --component <name>` delegates to the component's own idempotent,
|
||||
# fail-closed installer and EXITS. This path is ADDITIVE (#869): it does NOT run
|
||||
# the full-framework sync, does NOT alter framework-manifest ownership behavior,
|
||||
# and touches NOTHING the #869 install-ordering-guard covers (no runtime-asset
|
||||
# linking, no lease-enforcement hook wiring). Each component installer is
|
||||
# INTERSECTED-AND-VALIDATED against the single SSOT framework-manifest.txt, so a
|
||||
# component manifest can never authorize a write outside framework ownership.
|
||||
run_component_install() {
|
||||
local name="$1"
|
||||
case "$name" in
|
||||
wake)
|
||||
local wi="$SOURCE_DIR/tools/wake/wake-install.sh"
|
||||
if [[ ! -x "$wi" && ! -f "$wi" ]]; then
|
||||
fail "Component 'wake' installer not found at $wi"
|
||||
exit 1
|
||||
fi
|
||||
step "Installing Mosaic component: wake"
|
||||
WAKE_INSTALL_SOURCE="$SOURCE_DIR" WAKE_INSTALL_TARGET="$TARGET_DIR" \
|
||||
bash "$wi" install
|
||||
;;
|
||||
"")
|
||||
fail "--component requires a name (e.g. --component wake)."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
fail "Unknown component '$name'. Supported: wake."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ -n "$COMPONENT" ]]; then
|
||||
mkdir -p "$TARGET_DIR"
|
||||
run_component_install "$COMPONENT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Main
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -311,9 +669,26 @@ else
|
||||
ok "Install mode: overwrite"
|
||||
fi
|
||||
|
||||
# Pre-flight (keep mode): load + validate the framework manifest BEFORE taking a
|
||||
# snapshot or arming the restore trap. A fail-closed manifest (missing / empty /
|
||||
# malformed) must abort here WITHOUT deleting or restoring over operator files —
|
||||
# the snapshot/restore path exists only for a genuine mid-sync mutation failure,
|
||||
# not for a validation failure that has touched nothing yet (#791 blocker-1).
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
manifest_load
|
||||
# Durable, operator-scoped backup taken BEFORE any mutation (#791 PR2). Kept
|
||||
# outside the framework tree; recovered later via `mosaic restore`. Fail-open.
|
||||
make_durable_snapshot
|
||||
fi
|
||||
|
||||
# Snapshot before any destructive file operation; restore on interrupt/failure.
|
||||
# The trap MUST exit after restoring: a bash INT/TERM handler that merely returns
|
||||
# does NOT terminate the script — execution would resume past the interrupt,
|
||||
# clear the snapshot, and report success, leaving a partial post-interrupt update
|
||||
# (#791 blocker-A). `restore_snapshot; exit 1` guarantees a non-zero exit for
|
||||
# both the errtrace (ERR) and signal (INT/TERM) paths.
|
||||
make_snapshot
|
||||
trap 'restore_snapshot' ERR INT TERM
|
||||
trap 'restore_snapshot; exit 1' ERR INT TERM
|
||||
|
||||
sync_framework
|
||||
|
||||
@@ -334,6 +709,10 @@ reconcile_framework_files
|
||||
# Ensure tool scripts are executable
|
||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
|
||||
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
|
||||
# suffix — git resolves credential helpers by exact name/path, not extension —
|
||||
# so the *.sh glob above does not cover it; chmod it explicitly.
|
||||
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
|
||||
|
||||
ok "Framework synced to $TARGET_DIR"
|
||||
|
||||
@@ -342,6 +721,10 @@ run_migrations
|
||||
|
||||
# File-system phase complete and consistent — clear the restore trap.
|
||||
trap - ERR INT TERM
|
||||
# Post-sync safety net: heal any operator file a manifest bug let the sync touch,
|
||||
# using the durable pre-update snapshot (#791 PR2). Runs with the trap disarmed so
|
||||
# a corrective copy can't spuriously trigger a full rollback.
|
||||
verify_operator_surface # VERIFY-NET (#791 PR2)
|
||||
cleanup_snapshot
|
||||
|
||||
# Testability / minimal-install hook: stop after the file-system phase, before any
|
||||
@@ -357,10 +740,15 @@ step "Post-install tasks"
|
||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||
|
||||
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
|
||||
if "$SCRIPTS/mosaic-link-runtime-assets" >/dev/null 2>&1; then
|
||||
link_args=()
|
||||
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
|
||||
# stdout is suppressed as before, but stderr is left connected: the
|
||||
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
|
||||
# the operator, not be swallowed silently.
|
||||
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
|
||||
ok "Runtime assets linked"
|
||||
else
|
||||
warn "Runtime asset linking failed (non-fatal)"
|
||||
warn "Runtime asset linking failed (non-fatal) — see message above for details."
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,7 +1,48 @@
|
||||
{
|
||||
"model": "opus",
|
||||
"hooks": {
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
@@ -38,6 +79,11 @@
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry",
|
||||
"timeout": 3
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
export type LeaseLifecycleRunner = (args: string[]) => boolean;
|
||||
|
||||
type LifecycleEvent = {
|
||||
reason?: unknown;
|
||||
toolName?: unknown;
|
||||
};
|
||||
|
||||
type LifecycleHandler = (
|
||||
event: LifecycleEvent,
|
||||
context: Record<string, unknown>,
|
||||
) => unknown | Promise<unknown>;
|
||||
|
||||
export interface LeaseLifecyclePiApi {
|
||||
on(event: string, handler: LifecycleHandler): void;
|
||||
}
|
||||
|
||||
const ROLLOVER_REASONS = new Set(['reload', 'new', 'resume', 'fork']);
|
||||
|
||||
function eventReason(event: LifecycleEvent): string {
|
||||
return typeof event.reason === 'string' && event.reason.length > 0 ? event.reason : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Register redundant Pi compaction observers and same-PID generation rollover.
|
||||
*
|
||||
* A failed pre-compaction observer cancels compaction. A failed post-compaction
|
||||
* observer or generation rollover locally blocks later tools in addition to the
|
||||
* broker-backed all-tools gate.
|
||||
*/
|
||||
export function registerLeaseLifecycleHooks(
|
||||
pi: LeaseLifecyclePiApi,
|
||||
runRevoker: LeaseLifecycleRunner,
|
||||
): void {
|
||||
let postCompactReason: string | null = null;
|
||||
let postCompactFailure = false;
|
||||
let rolloverFailure = false;
|
||||
|
||||
pi.on('session_before_compact', async (event) => {
|
||||
const reason = eventReason(event);
|
||||
const revoked = runRevoker([
|
||||
'--runtime',
|
||||
'pi',
|
||||
'--reason',
|
||||
`pi-session-before-compact:${reason}`,
|
||||
]);
|
||||
if (!revoked) return { cancel: true };
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on('session_compact', async (event) => {
|
||||
postCompactReason = eventReason(event);
|
||||
});
|
||||
|
||||
pi.on('context', async () => {
|
||||
if (postCompactReason === null) return undefined;
|
||||
const reason = postCompactReason;
|
||||
const revoked = runRevoker([
|
||||
'--runtime',
|
||||
'pi',
|
||||
'--reason',
|
||||
`pi-context-after-compact:${reason}`,
|
||||
]);
|
||||
if (revoked) {
|
||||
postCompactReason = null;
|
||||
postCompactFailure = false;
|
||||
} else {
|
||||
postCompactFailure = true;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on('session_start', async (event) => {
|
||||
const reason = eventReason(event);
|
||||
if (!ROLLOVER_REASONS.has(reason)) return undefined;
|
||||
const revoked = runRevoker([
|
||||
'--runtime',
|
||||
'pi',
|
||||
'--reason',
|
||||
`pi-session-start:${reason}`,
|
||||
'--bump-generation',
|
||||
]);
|
||||
rolloverFailure = !revoked;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on('tool_call', async () => {
|
||||
if (!postCompactFailure && !rolloverFailure) return undefined;
|
||||
return {
|
||||
block: true,
|
||||
reason: 'BLOCKED: Mosaic lease lifecycle revoke failed; runtime remains UNVERIFIED.',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -22,12 +22,23 @@ import {
|
||||
import { join, basename } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
import { registerLeaseLifecycleHooks, type LeaseLifecyclePiApi } from './lease-lifecycle.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
const MUTATOR_GATE = join(MOSAIC_HOME, 'tools', 'lease-broker', 'mutator-gate.py');
|
||||
const LEASE_REVOKER = join(MOSAIC_HOME, 'tools', 'lease-broker', 'revoke-lease.py');
|
||||
const RECOVERY_COMMAND = join(MOSAIC_HOME, 'tools', 'lease-broker', 'recover-context.py');
|
||||
const RECEIPT_OBSERVER_CLIENT = join(
|
||||
MOSAIC_HOME,
|
||||
'tools',
|
||||
'lease-broker',
|
||||
'receipt-observer-client.py',
|
||||
);
|
||||
const RECOVERY_TOOL = 'mosaic_context_recover';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -106,6 +117,104 @@ function nowIso(): string {
|
||||
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
function runPiLeaseRevoker(args: string[]): boolean {
|
||||
const result = spawnSync('python3', [LEASE_REVOKER, ...args], {
|
||||
encoding: 'utf8',
|
||||
timeout: 2_000,
|
||||
env: process.env,
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function checkPiMutatorGate(toolName: string): { block: true; reason: string } | undefined {
|
||||
const result = spawnSync('python3', [MUTATOR_GATE, '--runtime', 'pi'], {
|
||||
input: `${JSON.stringify({ tool_name: toolName })}\n`,
|
||||
encoding: 'utf8',
|
||||
timeout: 2_000,
|
||||
env: process.env,
|
||||
});
|
||||
if (result.status === 0) return undefined;
|
||||
const detail = String(result.stderr ?? '')
|
||||
.trim()
|
||||
.split('\n')[0];
|
||||
return {
|
||||
block: true,
|
||||
reason: detail || 'BLOCKED: Mosaic mutator gate is unavailable or the lease is UNVERIFIED.',
|
||||
};
|
||||
}
|
||||
|
||||
function checkPiRecoveryGate(): { block: true; reason: string } | undefined {
|
||||
return checkPiMutatorGate(RECOVERY_TOOL);
|
||||
}
|
||||
|
||||
function assistantMessageText(message: unknown): string | undefined {
|
||||
if (typeof message !== 'object' || message === null) return undefined;
|
||||
const value = message as { role?: unknown; content?: unknown };
|
||||
if (value.role !== 'assistant') return undefined;
|
||||
if (typeof value.content === 'string') return value.content;
|
||||
if (!Array.isArray(value.content)) return undefined;
|
||||
const text: string[] = [];
|
||||
for (const part of value.content) {
|
||||
if (typeof part !== 'object' || part === null) return undefined;
|
||||
const typed = part as { type?: unknown; text?: unknown };
|
||||
if (typed.type !== 'text' || typeof typed.text !== 'string') return undefined;
|
||||
text.push(typed.text);
|
||||
}
|
||||
return text.join('');
|
||||
}
|
||||
|
||||
function recordPiMessageEnd(message: unknown): void {
|
||||
const latestAssistantMessage = assistantMessageText(message);
|
||||
if (latestAssistantMessage === undefined) return;
|
||||
// This sends finalized Pi message_end content only to the daemon-owned
|
||||
// authenticated observer transport, never to the public broker request API.
|
||||
spawnSync('python3', [RECEIPT_OBSERVER_CLIENT, '--runtime', 'pi'], {
|
||||
input: `${JSON.stringify({ latest_assistant_message: latestAssistantMessage })}\n`,
|
||||
encoding: 'utf8',
|
||||
timeout: 2_000,
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
function runPiRecoveryCommand(params: {
|
||||
phase: 'begin' | 'complete';
|
||||
construction?: string;
|
||||
compactionEpoch?: number;
|
||||
requestEpoch?: number;
|
||||
}): { content: Array<{ type: 'text'; text: string }> } {
|
||||
const args = [RECOVERY_COMMAND, params.phase];
|
||||
if (params.phase === 'begin') {
|
||||
if (
|
||||
typeof params.construction !== 'string' ||
|
||||
!Number.isInteger(params.compactionEpoch) ||
|
||||
!Number.isInteger(params.requestEpoch) ||
|
||||
params.compactionEpoch < 0 ||
|
||||
params.requestEpoch < 0
|
||||
) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: 'Recovery begin requires construction and non-negative epochs.' },
|
||||
],
|
||||
};
|
||||
}
|
||||
args.push(
|
||||
'--construction',
|
||||
params.construction,
|
||||
'--compaction-epoch',
|
||||
String(params.compactionEpoch),
|
||||
'--request-epoch',
|
||||
String(params.requestEpoch),
|
||||
);
|
||||
}
|
||||
const result = spawnSync('python3', args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 3_000,
|
||||
env: process.env,
|
||||
});
|
||||
const output = result.status === 0 ? String(result.stdout ?? '') : String(result.stderr ?? '');
|
||||
return { content: [{ type: 'text', text: output || 'Constrained recovery refused.' }] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mission detection
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -250,6 +359,40 @@ export default function register(pi: ExtensionAPI) {
|
||||
let hbModel: string | null = null;
|
||||
let hbTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Compaction observers and same-PID generation rollover ─────────────
|
||||
registerLeaseLifecycleHooks(pi as unknown as LeaseLifecyclePiApi, runPiLeaseRevoker);
|
||||
|
||||
// ── Whole mutator-class authorization gate ────────────────────────────
|
||||
// Every Pi tool, including unknown/custom tools, reaches the broker-backed
|
||||
// class gate before execution. Broker/script failure blocks fail-closed.
|
||||
pi.on('tool_call', async (event) => checkPiMutatorGate(event.toolName));
|
||||
|
||||
// Pi records only a finalized assistant entry at message_end. It never uses
|
||||
// after_provider_response, which occurs before stream consumption.
|
||||
pi.on('message_end', async (event) => {
|
||||
recordPiMessageEnd((event as unknown as { message?: unknown }).message);
|
||||
});
|
||||
|
||||
// The recovery custom tool is the only Pi invocation that maps to the
|
||||
// broker's exempt RECOVERY_TOOL identity. It is not a Bash exception.
|
||||
pi.registerTool({
|
||||
name: RECOVERY_TOOL,
|
||||
label: 'Mosaic Context Recovery',
|
||||
description:
|
||||
'Run the constrained broker-backed context recovery flow. This is the sole ungated mutator.',
|
||||
parameters: Type.Object({
|
||||
phase: Type.Union([Type.Literal('begin'), Type.Literal('complete')]),
|
||||
construction: Type.Optional(Type.String()),
|
||||
compactionEpoch: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
requestEpoch: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
}),
|
||||
async execute(_toolCallId, params) {
|
||||
const blocked = checkPiRecoveryGate();
|
||||
if (blocked !== undefined) return { content: [{ type: 'text', text: blocked.reason }] };
|
||||
return runPiRecoveryCommand(params);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Session Start ─────────────────────────────────────────────────────
|
||||
pi.on('session_start', async (_event, ctx) => {
|
||||
sessionCwd = process.cwd();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: mosaic-context-refresh
|
||||
description: Run the constrained Mosaic context-recovery flow after compaction or directive-loss. This is a thin wrapper over the broker-backed recovery command; it never treats a receipt as a safety or residency proof.
|
||||
---
|
||||
|
||||
# mosaic-context-refresh
|
||||
|
||||
Use this only after compaction, session resume, or confirmed directive drift. It invokes the
|
||||
**single ungated mutator**, `tools/lease-broker/recover-context.py`; every other consequential
|
||||
mutator remains behind the verified lease gate.
|
||||
|
||||
## Wrapper procedure
|
||||
|
||||
1. The runtime supplies the exact validated normative-fragment construction and the current
|
||||
compaction/request epochs.
|
||||
- **Claude:** invoke only this direct command shape (no shell composition):
|
||||
|
||||
```bash
|
||||
python3 /absolute/path/to/mosaic/tools/lease-broker/recover-context.py begin --construction /absolute/path/to/mosaic-context-refresh-construction.json --compaction-epoch 0 --request-epoch 0
|
||||
```
|
||||
|
||||
This is a literal argv template: replace the recover-context.py path and construction JSON path
|
||||
with the literal absolute paths for your install, then replace each epoch with literal decimal
|
||||
digits. Do not use variables, quoting, globs, redirects,
|
||||
shell operators, substitutions, or line continuations. Claude's all-tools gate maps only this
|
||||
fully literal recovery shape to `mosaic_context_recover`; ordinary `Bash` remains gated.
|
||||
|
||||
- **Pi:** call the registered `mosaic_context_recover` tool with `phase: "begin"`,
|
||||
`construction`, `compactionEpoch`, and `requestEpoch`. It is the exact broker-exempt tool name;
|
||||
Pi `bash` and every other tool remain gated.
|
||||
|
||||
Both forms delegate to the shipped WI-5 broker transition: revoke first, build the canonical
|
||||
`B_payload`/`H_payload`, enter `PENDING_DELIVERY`, and mint a fresh one-time challenge. They print
|
||||
the terminal receipt envelope to deliver exactly as returned.
|
||||
|
||||
2. The current assistant message copies that one terminal receipt verbatim. It does not compute a
|
||||
hash, add prose, quote a prior receipt, or present a caller-supplied receipt/challenge.
|
||||
3. The production trusted-observer transport records that finalized assistant entry before completion:
|
||||
- **Claude** selects the latest assistant entry at its `Stop` hook.
|
||||
- **Pi** records only finalized assistant content at `message_end` (never
|
||||
`after_provider_response`).
|
||||
|
||||
Then invoke completion with the same adapter form: Claude runs
|
||||
`python3 /absolute/path/to/mosaic/tools/lease-broker/recover-context.py complete`; Pi calls
|
||||
`mosaic_context_recover` with `phase: "complete"`. Completion supplies no receipt or challenge
|
||||
argument. The broker observes the exact latest assistant entry, commits evidence, consumes its own
|
||||
fresh challenge, and promotes VERIFIED last. If observation is absent, malformed, stale, or
|
||||
duplicated, recovery remains UNVERIFIED and a retry begins a new cycle.
|
||||
|
||||
## Scope and honesty
|
||||
|
||||
- A receipt from the normal verification path cannot be replayed through recovery: recovery mints a
|
||||
distinct current challenge and does not accept caller-provided receipt text as evidence.
|
||||
- Observable absent, malformed, prefix-truncated, and adapter-mutated terminal receipts do not
|
||||
promote. “Tail-only” is non-promoting only when the delivered terminal bytes are concretely
|
||||
malformed or incomplete.
|
||||
- **Negative capability:** a tail-preserving middle drop is not represented as receipt-detectable.
|
||||
It is a T-C injection-contract residual deferred to WI-7 server-side evidence; do not claim this
|
||||
skill or receipt catches it.
|
||||
- The receipt is a T-A delivery/liveness prerequisite only. It never proves obedience, comprehension,
|
||||
durable residency, or safety; the whole mutator-class gate and server-side branch protection retain
|
||||
those roles.
|
||||
|
||||
This source-resident skill is projected by the Mosaic skill bridge after framework install/upgrade.
|
||||
Do not create a live symlink manually.
|
||||
@@ -12,6 +12,8 @@ exact-match session.
|
||||
|
||||
- `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server.
|
||||
- `[email protected]` — user-mode template for one reusable agent session.
|
||||
- `[email protected]` — generic Pi operator-interaction template
|
||||
that fails fast when its pinned runtime policy is incomplete or changed.
|
||||
- `test-fleet-units.sh` — validates unit syntax and required relationships.
|
||||
|
||||
The agent template calls:
|
||||
@@ -22,36 +24,57 @@ The agent template calls:
|
||||
|
||||
which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`.
|
||||
|
||||
## Local customization
|
||||
## Generated environment and local data
|
||||
|
||||
Per-agent overrides live outside the package in:
|
||||
The roster-derived projection is written outside the package at:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/fleet/agents/<agent>.env
|
||||
~/.config/mosaic/fleet/agents/<agent>.env.generated
|
||||
```
|
||||
|
||||
Example:
|
||||
Systemd does not read either environment file. It starts the launcher with a fixed cleared bootstrap
|
||||
environment; before it creates, queries, or stops an exact agent tmux session, `start-agent-session.sh`
|
||||
strictly parses the generated projection and the optional local data file:
|
||||
|
||||
```dotenv
|
||||
MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||
MOSAIC_AGENT_RUNTIME=claude
|
||||
MOSAIC_AGENT_WORKDIR=$HOME/src/your-project
|
||||
# Optional escape hatch for PoC/canary agents:
|
||||
# MOSAIC_AGENT_COMMAND=mosaic yolo claude
|
||||
```text
|
||||
~/.config/mosaic/fleet/agents/<agent>.env.local
|
||||
```
|
||||
|
||||
The local file may contain only safe machine-specific data (`MOSAIC_RUNTIME_BIN`, heartbeat paths or
|
||||
interval, and Claude configuration paths). It cannot override roster-derived keys, carry a command,
|
||||
or contain secret-like/unknown keys. Both files must be private regular files. Do not hand-edit the
|
||||
generated projection; update the roster and regenerate it instead. A legacy `<agent>.env` is
|
||||
consumed only for regeneration, strict relocation, or private quarantine and is never launch input.
|
||||
|
||||
See `docs/fleet/reference/generated-env-boundary.md` for the full contract.
|
||||
|
||||
## Manual canary sequence
|
||||
|
||||
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
||||
edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
|
||||
helpers, and writes private roster-derived projections before any service is started.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
|
||||
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
|
||||
cp packages/mosaic/framework/tools/fleet/start-agent-session.sh ~/.config/mosaic/tools/fleet/
|
||||
chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh
|
||||
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
||||
mosaic fleet init --profile minimal --write
|
||||
mosaic fleet install
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start mosaic-tmux-holder.service
|
||||
systemctl --user start [email protected]
|
||||
mosaic fleet start canary-pi
|
||||
tmux -L mosaic-fleet ls
|
||||
```
|
||||
|
||||
For an operator-interaction service, first put `<agent-name>` in the roster with the pinned Pi
|
||||
runtime, model, reasoning, and `operator-interaction` tool policy. Re-run `mosaic fleet install` after
|
||||
that roster change so it writes `<agent-name>.env.generated`; ambient `MOSAIC_AGENT_*` values are not
|
||||
launch authority. The generic unit instance uses that generated identity, and no service source is
|
||||
renamed for an instance:
|
||||
|
||||
```bash
|
||||
mosaic fleet install
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start mosaic-interaction-agent@<agent-name>.service
|
||||
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh <agent-name>
|
||||
```
|
||||
|
||||
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
|
||||
to avoid disturbing the user's default tmux server.
|
||||
|
||||
@@ -7,16 +7,13 @@ PartOf=mosaic-tmux-holder.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Remove loader and noninteractive-shell controls before ExecStart loads env.
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
RemainAfterExit=yes
|
||||
# No default MOSAIC_TMUX_SOCKET: an absent roster socket means the literal
|
||||
# default tmux socket (no -L). The per-agent .env sets it when the roster names
|
||||
# one; otherwise it stays unset and start-agent-session.sh uses the default socket.
|
||||
Environment=MOSAIC_AGENT_NAME=%i
|
||||
Environment=MOSAIC_AGENT_RUNTIME=pi
|
||||
Environment=MOSAIC_AGENT_WORKDIR=%h
|
||||
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
|
||||
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
||||
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||
# Never preload the projection. The launcher starts from a fixed minimal
|
||||
# environment and strictly validates generated/local data before tmux effects.
|
||||
ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
||||
ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Mosaic operator interaction agent %i
|
||||
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
||||
Requires=mosaic-tmux-holder.service
|
||||
After=mosaic-tmux-holder.service
|
||||
PartOf=mosaic-tmux-holder.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Remove loader and noninteractive-shell controls before ExecStart loads env.
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
RemainAfterExit=yes
|
||||
# The interaction wrapper delegates to the shared strict parser before pinned
|
||||
# profile checks; no projection data reaches Bash through systemd.
|
||||
ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
|
||||
ExecStop=-/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-agent-session.sh --stop %i
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Mosaic lease broker daemon (framework tools/lease-broker/daemon.py)
|
||||
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# The broker socket lives under the runtime directory so it disappears with
|
||||
# the user session instead of surviving as stale state across logins.
|
||||
# daemon.py's secure_parent() fails closed unless this directory is exactly
|
||||
# 0700, so RuntimeDirectoryMode is not cosmetic.
|
||||
RuntimeDirectory=mosaic-lease
|
||||
RuntimeDirectoryMode=0700
|
||||
# Remove loader and noninteractive-shell controls before ExecStart loads env,
|
||||
# matching the tmux fleet units in this same directory.
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin XDG_RUNTIME_DIR=%t /bin/bash --noprofile --norc %h/.config/mosaic/tools/lease-broker/start-lease-broker.sh
|
||||
Restart=on-failure
|
||||
RestartSec=1
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -6,10 +6,11 @@ After=default.target
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet
|
||||
Environment=MOSAIC_TMUX_HOLDER=_holder
|
||||
ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"'
|
||||
ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
|
||||
# The holder owns the tmux server, so clear loader, shell-control, and stale
|
||||
# manager/session variables before the server process starts.
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh
|
||||
ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
[Unit]
|
||||
# Mosaic wake FALLBACK safety drain (F7 replacement-before-retirement, EPIC #892,
|
||||
# W7). This is the framework-shipped canon-side FALLBACK WAKE: a LOW-FREQUENCY
|
||||
# SAFETY drain that fires the canon drain (digest.sh render --from-store) on a
|
||||
# per-class cadence bound, INDEPENDENT of the event-driven detector daemon
|
||||
# (mosaic-wake.service). Its whole reason to exist is that a stalled/dead detector
|
||||
# or daemon can never SILENTLY STARVE delivery: even with nothing pushing, this
|
||||
# oneshot periodically drains the durable pending-inbox so the cumulative unacked
|
||||
# set still reaches the consumer. It is the §5 retirement precondition (F7) — it
|
||||
# must be live + proven-firing BEFORE the legacy mosaic-heartbeat@ timer is reaped,
|
||||
# so there is never a coverage gap. Fixed-interval heartbeats are forbidden as the
|
||||
# PRIMARY wake mechanism (WAKE-DOCTRINE); they survive ONLY as this per-class
|
||||
# fallback cadence, bounded by urgency SLO, never as the steady state.
|
||||
#
|
||||
# This is a oneshot SERVICE activated by mosaic-wake-fallback.timer; the cadence
|
||||
# lives on the TIMER (OnUnitActiveSec), set per-class by the A10 installer via the
|
||||
# blank-reset drop-in — never here. The service therefore carries NO [Install]
|
||||
# section (the TIMER is what is enabled/wanted); it is triggered, not wanted.
|
||||
Description=Mosaic wake fallback safety drain (canon drain: digest.sh render --from-store)
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
# Strip loader / noninteractive-shell controls before ExecStart, matching the
|
||||
# detector, lease-broker and tmux fleet units in this same directory (defense-in-
|
||||
# depth against an injected BASH_ENV/ENV/LD_PRELOAD in the user manager environment).
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
# Operator-owned runtime configuration. This EnvironmentFile carries only WAKE_*
|
||||
# NAMES (the per-agent namespace WAKE_AGENT, the lane WAKE_LANE, and — reused from
|
||||
# the detector — the pluggable adapter COMMANDS resolved BY NAME at runtime). It
|
||||
# carries NEVER any secret and NEVER any endpoint value. The '-' prefix keeps a
|
||||
# missing file from masking the installer's dedicated fail-closed install-validate.
|
||||
EnvironmentFile=-%h/.config/mosaic/wake/fallback.env
|
||||
# THE CANON DRAIN. digest.sh render --from-store drains the durable pending-inbox
|
||||
# (store.sh drain) and renders the cumulative-state digest. Running it here, on the
|
||||
# timer cadence, is the safety net: it is the SAME drain the delivery path uses, so
|
||||
# a stalled detector cannot starve it. Delivery/paste of the rendered digest is the
|
||||
# same operator-wired send seam the detector path uses (out of framework scope);
|
||||
# this unit guarantees the DRAIN fires on a bounded cadence regardless of detector
|
||||
# health. digest render exits 0 on an empty inbox, so a quiet cycle is a clean no-op.
|
||||
ExecStart=/bin/bash --noprofile --norc %h/.config/mosaic/tools/wake/digest.sh render --from-store
|
||||
@@ -0,0 +1,30 @@
|
||||
[Unit]
|
||||
# Cadence timer for the Mosaic wake FALLBACK safety drain (F7, EPIC #892, W7).
|
||||
# Drives mosaic-wake-fallback.service on a LOW-FREQUENCY per-class cadence bound,
|
||||
# INDEPENDENT of the event-driven detector daemon, so a stalled detector can never
|
||||
# silently starve delivery. This is the framework-shipped canon-side FALLBACK WAKE:
|
||||
# a heartbeat-shaped timer that survives ONLY as the per-class fallback cadence
|
||||
# (WAKE-DOCTRINE) bounded by urgency SLO — never the steady-state wake mechanism.
|
||||
Description=Mosaic wake fallback cadence timer (per-class safety wake)
|
||||
After=default.target
|
||||
|
||||
[Timer]
|
||||
# BASE cadence placeholder. The A10 installer OVERRIDES this per-class from the
|
||||
# watch-list schema's per-class `fallback_cadence` bound, via a BLANK-RESET drop-in
|
||||
# (an empty OnUnitActiveSec= reset line, then the new value) written under
|
||||
# mosaic-wake-fallback.timer.d/. systemd merges base + drop-ins so exactly ONE
|
||||
# effective OnUnitActiveUSec results (wake-install.sh verify-single). The base value
|
||||
# here is a conservative safety floor for a host installed before any per-class
|
||||
# drop-in is written — it is deliberately low-frequency (never the primary wake).
|
||||
OnUnitActiveSec=1h
|
||||
# Also fire shortly after boot so a freshly-booted host does not wait a full cadence
|
||||
# for its first safety drain. OnBootSec is a distinct key from OnUnitActiveSec and
|
||||
# does NOT count toward the exactly-one-OnUnitActiveUSec blank-reset invariant.
|
||||
OnBootSec=15min
|
||||
# Catch up a missed elapse (host asleep/off) rather than silently skipping it — a
|
||||
# fallback that silently skips is exactly the starvation this unit exists to prevent.
|
||||
Persistent=true
|
||||
Unit=mosaic-wake-fallback.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,31 @@
|
||||
[Unit]
|
||||
# Mosaic wake DETECTOR daemon (A1/W7 of the wake canon, EPIC #892). A LONG-LIVED
|
||||
# single-instance detector: tools/wake/detector.sh run. This is a SERVICE, not a
|
||||
# timer — the per-class SLO lives INSIDE the daemon's run-loop (WAKE_DETECTOR_INTERVAL
|
||||
# poll cadence + the per-cycle off-host beacon emit), never as a systemd
|
||||
# OnUnitActiveSec interval. The blank-reset cadence idiom therefore does NOT apply
|
||||
# to this unit; it applies only to the legacy mosaic-heartbeat@ timer during retire.
|
||||
Description=Mosaic wake detector daemon (framework tools/wake/detector.sh run)
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Strip loader / noninteractive-shell controls before ExecStart, matching the
|
||||
# lease-broker and tmux fleet units in this same directory (defense-in-depth
|
||||
# against an injected BASH_ENV/ENV/LD_PRELOAD in the user manager environment).
|
||||
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
|
||||
# Operator-owned runtime configuration. This EnvironmentFile carries only the
|
||||
# WAKE_* NAMES and the pluggable adapter COMMANDS (the off-host beacon/alarm sink
|
||||
# and the HMAC key NAME) — NEVER the HMAC key material and NEVER the alarm
|
||||
# endpoint value. Both are resolved BY NAME at runtime via load_credentials, so
|
||||
# no secret and no endpoint is ever written into this unit. The installer's
|
||||
# fail-closed install-validate (wake-install.sh validate-targets) is what proves
|
||||
# the required names are configured + reachable BEFORE this unit is enabled; the
|
||||
# '-' prefix keeps a missing file from masking that dedicated validation.
|
||||
EnvironmentFile=-%h/.config/mosaic/wake/detector.env
|
||||
ExecStart=/bin/bash --noprofile --norc %h/.config/mosaic/tools/wake/detector.sh run
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -4,6 +4,9 @@ set -euo pipefail
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
||||
AGENT="$SCRIPT_DIR/[email protected]"
|
||||
INTERACTION="$SCRIPT_DIR/[email protected]"
|
||||
HOLDER_START="$SCRIPT_DIR/../../tools/fleet/start-tmux-holder.sh"
|
||||
START_AGENT="$SCRIPT_DIR/../../tools/fleet/start-agent-session.sh"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
@@ -12,19 +15,145 @@ fail() {
|
||||
|
||||
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
||||
[ -f "$AGENT" ] || fail "missing [email protected]"
|
||||
[ -f "$INTERACTION" ] || fail "missing [email protected]"
|
||||
[ -x "$HOLDER_START" ] || fail "missing executable start-tmux-holder.sh"
|
||||
[ -x "$START_AGENT" ] || fail "missing executable start-agent-session.sh"
|
||||
|
||||
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
||||
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
||||
grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
|
||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$HOLDER" || \
|
||||
fail "holder does not remove loader and shell-control variables"
|
||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet MOSAIC_TMUX_HOLDER=_holder /bin/bash --noprofile --norc %h/.config/mosaic/tools/fleet/start-tmux-holder.sh' "$HOLDER" || \
|
||||
fail "holder does not clear manager environment before starting tmux"
|
||||
grep -qF 'ExecStop=-/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin MOSAIC_TMUX_SOCKET=mosaic-fleet /bin/bash --noprofile --norc -c' "$HOLDER" || \
|
||||
fail "holder stop does not clear manager environment"
|
||||
if grep -qF -- '/bin/bash -lc' "$HOLDER"; then
|
||||
fail "holder must not start tmux through a login shell"
|
||||
fi
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
||||
if grep -qE '^Environment(File)?=' "$AGENT" "$INTERACTION"; then
|
||||
fail "agent units must not accept ambient or projection environment before strict parsing"
|
||||
fi
|
||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$AGENT" || \
|
||||
fail "agent unit does not remove loader and shell-control variables"
|
||||
grep -qF 'UnsetEnvironment=LD_PRELOAD BASH_ENV ENV' "$INTERACTION" || \
|
||||
fail "interaction unit does not remove loader and shell-control variables"
|
||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$AGENT" || \
|
||||
fail "agent unit does not clear bootstrap environment before strict parsing"
|
||||
grep -qF 'start-agent-session.sh --stop %i' "$AGENT" || \
|
||||
fail "agent stop does not use the validated exact-stop path"
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder"
|
||||
grep -qF 'ExecStart=/usr/bin/env -i HOME=%h MOSAIC_AGENT_NAME=%i PATH=/usr/bin:/bin /bin/bash --noprofile --norc' "$INTERACTION" || \
|
||||
fail "interaction unit does not clear bootstrap environment before strict parsing"
|
||||
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not use shared strict parsing"
|
||||
grep -qF 'start-agent-session.sh --stop %i' "$INTERACTION" || \
|
||||
fail "interaction stop does not use the validated exact-stop path"
|
||||
|
||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||
systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||
cat /tmp/mosaic-fleet-systemd-verify.log >&2
|
||||
fail "systemd-analyze verify failed"
|
||||
}
|
||||
fi
|
||||
|
||||
# Real isolated socket regression: a preexisting server with an LD_PRELOAD
|
||||
# constructor marker must fail closed, while a fresh named server is created.
|
||||
if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
|
||||
TEST_ROOT=$(mktemp -d)
|
||||
TEST_SOCKET="mosaic-holder-test-$$"
|
||||
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
||||
MARKER="$TEST_ROOT/loader-marker"
|
||||
LIBRARY="$TEST_ROOT/marker.so"
|
||||
HOLDER_HOME="$TEST_ROOT/holder-home"
|
||||
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
||||
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > \
|
||||
"$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
chmod 600 "$HOLDER_HOME/.config/mosaic/fleet/run/holder-owner"
|
||||
cat > "$TEST_ROOT/marker.c" <<'EOF'
|
||||
#include <fcntl.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
__attribute__((constructor)) static void mark_loader(void) {
|
||||
const char *path = getenv("MOSAIC_LOADER_MARKER");
|
||||
if (path != NULL) {
|
||||
int fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600);
|
||||
if (fd >= 0) { write(fd, "loaded\\n", 7); close(fd); }
|
||||
}
|
||||
}
|
||||
EOF
|
||||
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
||||
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
|
||||
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
||||
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
||||
: > "$MARKER"
|
||||
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START" \
|
||||
>"$TEST_ROOT/holder.out" 2>&1; then
|
||||
fail "holder adopted contaminated named server"
|
||||
fi
|
||||
grep -qF 'global environment does not match the owned-server contract' "$TEST_ROOT/holder.out" || \
|
||||
fail "holder did not report contaminated server environment"
|
||||
[ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \
|
||||
fail "holder replaced a contaminated server instead of failing closed"
|
||||
[ ! -s "$MARKER" ] || fail "holder execution triggered a contaminated loader"
|
||||
|
||||
# Agent validation must reject the same unmanaged server without cleaning its
|
||||
# global environment or adding a managed session.
|
||||
AGENT_HOME="$HOLDER_HOME/.config/mosaic"
|
||||
AGENT_NAME=loader-safe
|
||||
AGENT_WORKDIR="$AGENT_HOME/work"
|
||||
AGENT_BIN="$TEST_ROOT/agent-bin"
|
||||
mkdir -p "$AGENT_HOME/fleet/agents" "$AGENT_WORKDIR" "$AGENT_BIN"
|
||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=
|
||||
MOSAIC_AGENT_REASONING=
|
||||
MOSAIC_AGENT_TOOL_POLICY=code
|
||||
MOSAIC_AGENT_WORKDIR=$AGENT_WORKDIR
|
||||
MOSAIC_TMUX_SOCKET=$TEST_SOCKET
|
||||
EOF
|
||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$AGENT_BIN" > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local"
|
||||
chmod 600 "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" \
|
||||
"$AGENT_HOME/fleet/agents/$AGENT_NAME.env.local"
|
||||
cat > "$AGENT_BIN/mosaic" <<'EOF'
|
||||
#!/bin/sh
|
||||
sleep 30
|
||||
EOF
|
||||
chmod 700 "$AGENT_BIN/mosaic"
|
||||
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
|
||||
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
|
||||
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||
"$START_AGENT" "$AGENT_NAME" >"$TEST_ROOT/agent.out" 2>&1; then
|
||||
fail "agent launcher adopted contaminated named server"
|
||||
fi
|
||||
[ "$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')" = "$server_pid" ] || \
|
||||
fail "agent launcher changed unmanaged server PID"
|
||||
[ "$(tmux -L "$TEST_SOCKET" show-environment -g | sort)" = "$server_environment_before" ] || \
|
||||
fail "agent launcher changed unmanaged global environment"
|
||||
[ "$(tmux -L "$TEST_SOCKET" list-sessions | sort)" = "$server_sessions_before" ] || \
|
||||
fail "agent launcher changed unmanaged sessions"
|
||||
tmux -L "$TEST_SOCKET" kill-server
|
||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
||||
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
||||
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
|
||||
fail "fresh holder retained LD_PRELOAD"
|
||||
fi
|
||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||
"$START_AGENT" "$AGENT_NAME"
|
||||
tmux -L "$TEST_SOCKET" has-session -t "=$AGENT_NAME:0.0" || \
|
||||
fail "agent did not launch on a valid owned server"
|
||||
tmux -L "$TEST_SOCKET" kill-server
|
||||
trap - EXIT
|
||||
rm -rf "$TEST_ROOT"
|
||||
fi
|
||||
|
||||
echo "ok - fleet systemd unit templates"
|
||||
|
||||
@@ -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 queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
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 CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
|
||||
## 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 -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
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 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>`.
|
||||
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 queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
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 CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
|
||||
|
||||
## 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 -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
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 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>`.
|
||||
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 queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
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 CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
|
||||
|
||||
## 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 -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
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 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>`.
|
||||
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 queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
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 CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
|
||||
## 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 -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
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 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>`.
|
||||
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 queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
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 CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
|
||||
## 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 -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
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 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>`.
|
||||
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 queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
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 CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
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.
|
||||
|
||||
## 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 -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||
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 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>`.
|
||||
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.
|
||||
|
||||
@@ -15,13 +15,23 @@
|
||||
#
|
||||
# After loading, service-specific env vars are exported.
|
||||
# Run `load_credentials --help` for details.
|
||||
#
|
||||
# Resolution order (first match wins):
|
||||
# 1. $MOSAIC_CREDENTIALS_FILE (explicit override — never second-guessed)
|
||||
# 2. $HOME/.config/mosaic/credentials.json
|
||||
# 3. /etc/mosaic/credentials.json (host-level fallback)
|
||||
# The /etc fallback exists for HOME-redirected profile environments, where
|
||||
# $HOME points at a per-profile directory that has no credentials file.
|
||||
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
|
||||
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
|
||||
|
||||
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
|
||||
for _cand in "$HOME/.config/mosaic/credentials.json"; do
|
||||
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
|
||||
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
|
||||
done
|
||||
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}"
|
||||
fi
|
||||
export MOSAIC_CREDENTIALS_FILE
|
||||
|
||||
_mosaic_require_jq() {
|
||||
if ! command -v jq &>/dev/null; then
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared bash reader for framework-manifest.txt (#791).
|
||||
#
|
||||
# This is the bash half of the SSOT ownership resolver; the TypeScript half is
|
||||
# packages/mosaic/src/framework/manifest.ts. BOTH read the same
|
||||
# framework-manifest.txt and MUST resolve identical ownership for any path — the
|
||||
# parity test (manifest-parity.spec.ts) invokes this file's `resolve` CLI and
|
||||
# compares it against the TS resolver, so the two can never drift (the #631
|
||||
# two-copies failure class this closes).
|
||||
#
|
||||
# Ownership resolution (deny-wins / fail-safe):
|
||||
# 1. operator glob matches -> operator
|
||||
# 2. else framework glob -> framework
|
||||
# 3. else -> operator (UNKNOWN defaults to operator, #791)
|
||||
#
|
||||
# Globs are compiled once at load into exact-prefix checks or POSIX EREs, so the
|
||||
# hot resolver (manifest_is_framework) forks no subprocesses — the installer
|
||||
# calls it once per file across the whole tree.
|
||||
#
|
||||
# Usage as a library (source it, then):
|
||||
# manifest_load [manifest-file] # populates + compiles the manifest
|
||||
# manifest_is_framework <rel-path> # rc 0 = framework-owned, rc 1 = operator
|
||||
# manifest_resolve <rel-path> # echoes: framework | operator
|
||||
# manifest_subtree_roots # echoes shipped framework `dir/**` roots
|
||||
#
|
||||
# Usage as a CLI (parity harness):
|
||||
# bash manifest.sh resolve <rel-path>
|
||||
# bash manifest.sh subtree-roots
|
||||
# bash manifest.sh classify # reads paths on stdin -> "<own>\t<path>"
|
||||
|
||||
MANIFEST_FRAMEWORK=()
|
||||
MANIFEST_OPERATOR=()
|
||||
|
||||
# Compiled forms (parallel arrays). _*_KIND[i] is "exact" or "re".
|
||||
_MF_KIND=(); _MF_EXACT=(); _MF_RE=()
|
||||
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
|
||||
_MF_ROOTS=()
|
||||
|
||||
_manifest_default_root() { cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd; }
|
||||
|
||||
# Normalize a path/glob: backslashes -> slashes, strip leading ./ and /, strip
|
||||
# trailing / (mirrors normalizeRel in manifest.ts).
|
||||
_manifest_norm() {
|
||||
local p="$1"
|
||||
p="${p//\\//}"
|
||||
p="${p#./}"
|
||||
while [[ "$p" == /* ]]; do p="${p#/}"; done
|
||||
while [[ "$p" == */ ]]; do p="${p%/}"; done
|
||||
printf '%s' "$p"
|
||||
}
|
||||
|
||||
# Translate a normalized glob into a POSIX ERE body (mirrors globToRegExpBody).
|
||||
_manifest_glob_to_ere() {
|
||||
local pattern; pattern="$(_manifest_norm "$1")"
|
||||
local out="" c n i len=${#pattern} trailing
|
||||
for (( i = 0; i < len; i++ )); do
|
||||
c="${pattern:i:1}"
|
||||
if [[ "$c" == "*" ]]; then
|
||||
n="${pattern:i+1:1}"
|
||||
if [[ "$n" == "*" ]]; then
|
||||
i=$((i + 1))
|
||||
trailing=0
|
||||
if [[ "${pattern:i+1:1}" == "/" ]]; then i=$((i + 1)); trailing=1; fi
|
||||
if [[ "$out" == */ ]]; then
|
||||
out="${out%/}(/.*)?"
|
||||
elif [[ "$trailing" -eq 1 ]]; then
|
||||
out="$out(.*/)?"
|
||||
else
|
||||
out="$out.*"
|
||||
fi
|
||||
else
|
||||
out="$out[^/]*"
|
||||
fi
|
||||
else
|
||||
case "$c" in
|
||||
.|+|\?|^|\$|\{|\}|\(|\)|\||\[|\]|\\) out="$out\\$c" ;;
|
||||
*) out="$out$c" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
# Compile one raw glob into (kind, exact, re) appended to the given section.
|
||||
# $1 = raw glob, $2 = section letter (F|O).
|
||||
_manifest_compile_one() {
|
||||
local norm; norm="$(_manifest_norm "$1")"
|
||||
[[ -n "$norm" ]] || return 0
|
||||
if [[ "$norm" == *"*"* ]]; then
|
||||
local re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
if [[ "$2" == F ]]; then
|
||||
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
||||
else
|
||||
_MO_KIND+=(re); _MO_EXACT+=(""); _MO_RE+=("$re")
|
||||
fi
|
||||
else
|
||||
if [[ "$2" == F ]]; then
|
||||
_MF_KIND+=(exact); _MF_EXACT+=("$norm"); _MF_RE+=("")
|
||||
else
|
||||
_MO_KIND+=(exact); _MO_EXACT+=("$norm"); _MO_RE+=("")
|
||||
fi
|
||||
fi
|
||||
[[ "$2" == F && "$norm" == */"**" ]] && _MF_ROOTS+=("${norm%/**}")
|
||||
return 0
|
||||
}
|
||||
|
||||
_manifest_compile() {
|
||||
_MF_KIND=(); _MF_EXACT=(); _MF_RE=(); _MF_ROOTS=()
|
||||
_MO_KIND=(); _MO_EXACT=(); _MO_RE=()
|
||||
local g
|
||||
for g in "${MANIFEST_FRAMEWORK[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" F; done
|
||||
for g in "${MANIFEST_OPERATOR[@]:-}"; do [[ -n "$g" ]] && _manifest_compile_one "$g" O; done
|
||||
# Explicit success: an empty operator array makes the final `[[ -n "" ]] && …`
|
||||
# short-circuit to rc 1, which would otherwise become this function's (and
|
||||
# manifest_load's) return code — a spurious failure (#791 B2). Never rely on
|
||||
# the last loop's exit status here.
|
||||
return 0
|
||||
}
|
||||
|
||||
# Load + compile the manifest. Rejects a malformed file the same way
|
||||
# parseManifest() does (entry before a section header / unknown header).
|
||||
manifest_load() {
|
||||
local file="${1:-}"
|
||||
[[ -n "$file" ]] || file="$(_manifest_default_root)/framework-manifest.txt"
|
||||
# Fail CLOSED on a missing/unreadable manifest. Without this, `done < "$file"`
|
||||
# aborts on a raw redirection error with no explanation; downstream that reads
|
||||
# as "no framework paths" and an upgrade could no-op silently (#791 B2/B3).
|
||||
if [[ ! -r "$file" ]]; then
|
||||
echo "manifest: cannot read manifest file: $file — refusing to sync (fail-closed)." >&2
|
||||
return 1
|
||||
fi
|
||||
MANIFEST_FRAMEWORK=()
|
||||
MANIFEST_OPERATOR=()
|
||||
local section="" line
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="${line#"${line%%[![:space:]]*}"}" # ltrim
|
||||
line="${line%"${line##*[![:space:]]}"}" # rtrim
|
||||
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
|
||||
case "$line" in
|
||||
"[framework]") section=framework; continue ;;
|
||||
"[operator]") section=operator; continue ;;
|
||||
"["*) echo "manifest: unknown section header: $line" >&2; return 1 ;;
|
||||
esac
|
||||
if [[ -z "$section" ]]; then
|
||||
echo "manifest: entry before any [section] header: $line" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$section" == framework ]]; then
|
||||
MANIFEST_FRAMEWORK+=("$line")
|
||||
else
|
||||
MANIFEST_OPERATOR+=("$line")
|
||||
fi
|
||||
done < "$file"
|
||||
# An empty or comment-only manifest defines NO framework-owned paths. Treating
|
||||
# that as valid would make every path resolve operator and an upgrade prune
|
||||
# nothing / write nothing — a silent no-op indistinguishable from success.
|
||||
# Fail loud instead, mirroring parseManifest()'s throw in manifest.ts (#791 B2).
|
||||
if [[ ${#MANIFEST_FRAMEWORK[@]} -eq 0 ]]; then
|
||||
echo "manifest: no [framework] entries in $file — refusing to sync (empty or malformed manifest)." >&2
|
||||
return 1
|
||||
fi
|
||||
# An entry like `/` or `./` normalizes to nothing and compiles to a glob that
|
||||
# matches no path — so a manifest whose only [framework] entries are degenerate
|
||||
# passes the count guard above but leaves the framework matcher empty: every
|
||||
# path resolves operator, the exact silent no-op we fail closed against. Require
|
||||
# at least one entry with a real (non-slash, non-dot) character. Mirrors
|
||||
# parseManifest()'s `isUsableFrameworkGlob` `/[^/.]/` test in manifest.ts (#791 blocker-B).
|
||||
local _g _usable=0
|
||||
for _g in "${MANIFEST_FRAMEWORK[@]:-}"; do
|
||||
if [[ "$(_manifest_norm "$_g")" =~ [^/.] ]]; then _usable=1; break; fi
|
||||
done
|
||||
if [[ "$_usable" -eq 0 ]]; then
|
||||
echo "manifest: no usable [framework] entries in $file (every entry is empty or a bare dot segment) — refusing to sync (malformed manifest)." >&2
|
||||
return 1
|
||||
fi
|
||||
_manifest_compile
|
||||
return 0
|
||||
}
|
||||
|
||||
# Fork-free: does $1 (a mosaic-home-relative path) match an operator glob?
|
||||
_mo_matches() {
|
||||
local path="$1" i n=${#_MO_KIND[@]} re pat
|
||||
for (( i = 0; i < n; i++ )); do
|
||||
if [[ "${_MO_KIND[i]}" == exact ]]; then
|
||||
pat="${_MO_EXACT[i]}"
|
||||
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||
else
|
||||
re="${_MO_RE[i]}"
|
||||
[[ "$path" =~ $re ]] && return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Fork-free: does $1 match a framework glob?
|
||||
_mf_matches() {
|
||||
local path="$1" i n=${#_MF_KIND[@]} re pat
|
||||
for (( i = 0; i < n; i++ )); do
|
||||
if [[ "${_MF_KIND[i]}" == exact ]]; then
|
||||
pat="${_MF_EXACT[i]}"
|
||||
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||
else
|
||||
re="${_MF_RE[i]}"
|
||||
[[ "$path" =~ $re ]] && return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# The installer hot path — no subshell. rc 0 = framework-owned, rc 1 = operator
|
||||
# (deny-wins / fail-safe). Assumes an already-clean POSIX relative path.
|
||||
manifest_is_framework() {
|
||||
_mo_matches "$1" && return 1
|
||||
_mf_matches "$1" && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
# Echo the ownership of a path: framework | operator. Normalizes first, so it is
|
||||
# safe for CLI / test callers passing unnormalized input.
|
||||
manifest_resolve() {
|
||||
local path; path="$(_manifest_norm "$1")"
|
||||
if manifest_is_framework "$path"; then echo framework; else echo operator; fi
|
||||
}
|
||||
|
||||
# Echo each shipped framework subtree root (a `dir/**` entry, without the /**).
|
||||
manifest_subtree_roots() {
|
||||
local r
|
||||
for r in "${_MF_ROOTS[@]:-}"; do [[ -n "$r" ]] && printf '%s\n' "$r"; done
|
||||
}
|
||||
|
||||
# CLI dispatch — only when executed directly, never when sourced.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
set -o pipefail
|
||||
# Propagate a fail-closed manifest_load (missing/empty/malformed) as a non-zero
|
||||
# exit instead of continuing to resolve against empty compiled arrays — that is
|
||||
# what lets the parity test assert bash and TS reject the same bad inputs (#791 B2).
|
||||
manifest_load "${MANIFEST_FILE:-}" || exit 1
|
||||
cmd="${1:-}"
|
||||
case "$cmd" in
|
||||
resolve) manifest_resolve "${2:?path required}" ;;
|
||||
subtree-roots) manifest_subtree_roots ;;
|
||||
classify)
|
||||
while IFS= read -r p; do
|
||||
[[ -z "$p" ]] && continue
|
||||
printf '%s\t%s\n' "$(manifest_resolve "$p")" "$p"
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "usage: manifest.sh {resolve <path>|subtree-roots|classify}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
@@ -4,6 +4,22 @@ set -euo pipefail
|
||||
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
backup_stamp="$(date +%Y%m%d%H%M%S)"
|
||||
|
||||
# ─── Install-ordering guard opt-out (#869 Point-1 C2) ───────────────────────
|
||||
# Explicit, per-invocation CLI flag ONLY — deliberately NOT read from an
|
||||
# environment variable, so it can never sit as a silently-inherited default in
|
||||
# a shell profile or CI env. Absent (the default) => hard fail-loud path.
|
||||
allow_inactive_enforcement=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--allow-inactive-enforcement) allow_inactive_enforcement=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Tracks whether the Claude settings install-ordering guard (below) reported a
|
||||
# degraded (enforcement-not-wired) outcome, so this script's own exit status
|
||||
# reflects it even though the rest of the runtime-asset sync must still run.
|
||||
guard_degraded=0
|
||||
|
||||
copy_file_managed() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
@@ -24,6 +40,103 @@ copy_file_managed() {
|
||||
cp "$src" "$dst"
|
||||
}
|
||||
|
||||
# ─── Install-ordering guard for settings.json (#869 Point-1 C2) ─────────────
|
||||
#
|
||||
# settings.json is where #828's enforcement hooks (PreToolUse mutator-gate.py,
|
||||
# Stop receipt-observer-client.py) get wired unconditionally. Before copying
|
||||
# it, delegate to `mosaic __link-claude-settings` (packages/mosaic/src/commands/
|
||||
# install-ordering-guard.ts) so the wiring decision is made by importing the
|
||||
# C1 activation probe (`leaseEnforcementActivatable()`) directly, rather than
|
||||
# re-implementing the capability/supervisor checks in shell. That subcommand:
|
||||
# - activatable -> writes settings.json with hooks intact, exits 0
|
||||
# - NOT activatable -> writes settings.json with hooks STRIPPED,
|
||||
# prints an actionable message, exits 1
|
||||
# - NOT activatable + opt-out -> writes settings.json with hooks intact,
|
||||
# prints a loud warning, exits 0
|
||||
# The `mosaic` CLI is expected on PATH at this point ("No executables are
|
||||
# placed on PATH — the mosaic npm CLI is the only binary", per install.sh).
|
||||
# If it is not resolvable at all, that is itself strong evidence the
|
||||
# activation half is absent, so the same fail-loud default applies via a
|
||||
# minimal python3 fallback (this repo already depends on python3 for the
|
||||
# lease broker itself).
|
||||
copy_claude_settings_guarded() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
|
||||
local guard_args=(__link-claude-settings "$src" "$dst")
|
||||
if [[ "$allow_inactive_enforcement" == "1" ]]; then
|
||||
guard_args+=(--allow-inactive-enforcement)
|
||||
fi
|
||||
|
||||
if command -v mosaic >/dev/null 2>&1; then
|
||||
if mosaic "${guard_args[@]}"; then
|
||||
return 0
|
||||
fi
|
||||
echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2
|
||||
guard_degraded=1
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement" >&2
|
||||
echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2
|
||||
echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2
|
||||
echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2
|
||||
|
||||
if [[ "$allow_inactive_enforcement" == "1" ]]; then
|
||||
echo "[mosaic-link] WARNING: --allow-inactive-enforcement set — wiring $dst AS-IS (with" >&2
|
||||
echo "[mosaic-link] enforcement hooks) despite being unable to confirm activation." >&2
|
||||
copy_file_managed "$src" "$dst"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$src" "$dst" <<'PYEOF'
|
||||
import json, sys
|
||||
|
||||
src, dest = sys.argv[1], sys.argv[2]
|
||||
with open(src) as f:
|
||||
data = json.load(f)
|
||||
|
||||
hooks = data.get("hooks", {})
|
||||
|
||||
pre = hooks.get("PreToolUse", [])
|
||||
hooks["PreToolUse"] = [
|
||||
t for t in pre
|
||||
if not any("mutator-gate.py" in h.get("command", "") for h in t.get("hooks", []))
|
||||
]
|
||||
if not hooks["PreToolUse"]:
|
||||
del hooks["PreToolUse"]
|
||||
|
||||
stop = hooks.get("Stop", [])
|
||||
new_stop = []
|
||||
for t in stop:
|
||||
kept = [h for h in t.get("hooks", []) if "receipt-observer-client.py" not in h.get("command", "")]
|
||||
if kept:
|
||||
t = dict(t)
|
||||
t["hooks"] = kept
|
||||
new_stop.append(t)
|
||||
if new_stop:
|
||||
hooks["Stop"] = new_stop
|
||||
elif "Stop" in hooks:
|
||||
del hooks["Stop"]
|
||||
|
||||
if hooks:
|
||||
data["hooks"] = hooks
|
||||
else:
|
||||
data.pop("hooks", None)
|
||||
|
||||
with open(dest, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write("\n")
|
||||
PYEOF
|
||||
else
|
||||
cp "$src" "$dst"
|
||||
fi
|
||||
guard_degraded=1
|
||||
return 0
|
||||
}
|
||||
|
||||
remove_legacy_path() {
|
||||
local p="$1"
|
||||
|
||||
@@ -110,6 +223,13 @@ for runtime_file in \
|
||||
fi
|
||||
src="$MOSAIC_HOME/runtime/claude/$runtime_file"
|
||||
[[ -f "$src" ]] || continue
|
||||
if [[ "$runtime_file" == "settings.json" ]]; then
|
||||
# Install-ordering guard (#869 Point-1 C2): gate enforcement-hook wiring
|
||||
# on confirmed activation instead of the plain copy_file_managed used for
|
||||
# every other runtime file. See copy_claude_settings_guarded() above.
|
||||
copy_claude_settings_guarded "$src" "$HOME/.claude/$runtime_file"
|
||||
continue
|
||||
fi
|
||||
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
|
||||
done
|
||||
|
||||
@@ -167,3 +287,12 @@ fi
|
||||
|
||||
echo "[mosaic-link] Runtime assets synced (non-symlink mode)"
|
||||
echo "[mosaic-link] Canonical source: $MOSAIC_HOME"
|
||||
|
||||
# Propagate the install-ordering guard's outcome (#869 Point-1 C2): every
|
||||
# other runtime asset above is best-effort/non-fatal, but a degraded
|
||||
# (enforcement-not-wired) settings.json must make THIS script's own exit
|
||||
# status non-zero so callers (framework/install.sh, finalize.ts) can surface
|
||||
# it — never silently.
|
||||
if [[ "$guard_degraded" == "1" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -161,6 +161,7 @@ link_targets=(
|
||||
)
|
||||
|
||||
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
|
||||
local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")"
|
||||
|
||||
# Build an associative array from the colon-separated whitelist for O(1) lookup.
|
||||
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
|
||||
@@ -203,7 +204,14 @@ link_skill_into_target() {
|
||||
link_path="$target_dir/$name"
|
||||
|
||||
if [[ -L "$link_path" ]]; then
|
||||
ln -sfn "$skill_path" "$link_path"
|
||||
local raw_target resolved_target
|
||||
raw_target="$(readlink "$link_path")"
|
||||
resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")"
|
||||
if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then
|
||||
ln -sfn "$skill_path" "$link_path"
|
||||
else
|
||||
echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -234,14 +242,10 @@ prune_stale_links_in_target() {
|
||||
continue
|
||||
fi
|
||||
|
||||
resolved="$(readlink -f "$link_path" 2>/dev/null || true)"
|
||||
if [[ -z "$resolved" ]]; then
|
||||
rm -f "$link_path"
|
||||
echo "[mosaic-skills] Removed stale broken skill link: $link_path"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$resolved" == "$MOSAIC_HOME/"* ]]; then
|
||||
# -m resolves lexical dangling targets too. If resolution fails, ownership
|
||||
# is unproven and the link must be preserved.
|
||||
resolved="$(readlink -m "$link_path" 2>/dev/null || true)"
|
||||
if [[ -n "$resolved" && "$resolved" == "$canonical_real/"* ]]; then
|
||||
rm -f "$link_path"
|
||||
echo "[mosaic-skills] Removed stale retired skill link: $link_path"
|
||||
fi
|
||||
|
||||
@@ -79,9 +79,26 @@ function Link-SkillIntoTarget {
|
||||
|
||||
$linkPath = Join-Path $TargetDir $name
|
||||
|
||||
# Already a junction/symlink — recreate
|
||||
# Recreate only Mosaic-owned junctions/symlinks. Foreign reparse points are
|
||||
# runtime-owned and must never be clobbered by install/upgrade auto-sync.
|
||||
$existing = Get-Item $linkPath -Force -ErrorAction SilentlyContinue
|
||||
if ($existing -and ($existing.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
$rawTarget = @($existing.Target)[0]
|
||||
$candidate = if ([System.IO.Path]::IsPathRooted($rawTarget)) {
|
||||
$rawTarget
|
||||
}
|
||||
else {
|
||||
Join-Path (Split-Path $linkPath -Parent) $rawTarget
|
||||
}
|
||||
$resolvedTarget = [System.IO.Path]::GetFullPath($candidate)
|
||||
$canonicalRoot = [System.IO.Path]::GetFullPath($MosaicSkillsDir).TrimEnd('\') + '\'
|
||||
$localRoot = [System.IO.Path]::GetFullPath($MosaicLocalSkillsDir).TrimEnd('\') + '\'
|
||||
$owned = $resolvedTarget.StartsWith($canonicalRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$resolvedTarget.StartsWith($localRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
if (-not $owned) {
|
||||
Write-Host "[mosaic-skills] Preserve foreign runtime symlink: $linkPath"
|
||||
return
|
||||
}
|
||||
Remove-Item $linkPath -Force
|
||||
}
|
||||
elseif ($existing) {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for issue #869 Point-1 C2 — the install-ordering guard
|
||||
# wired into mosaic-link-runtime-assets.
|
||||
#
|
||||
# Root cause under test: mosaic-link-runtime-assets copies
|
||||
# runtime/claude/settings.json (which embeds the PreToolUse mutator-gate.py
|
||||
# hook and the Stop receipt-observer-client.py hook) straight into
|
||||
# ~/.claude/settings.json, unconditionally. If the lease-broker activation
|
||||
# half cannot be confirmed on this host, wiring those hooks bricks it with a
|
||||
# fail-closed gate that can never be satisfied.
|
||||
#
|
||||
# This harness never invokes a real `mosaic` CLI build — it stubs the
|
||||
# `__link-claude-settings` contract with a fake `mosaic` on PATH so the shell
|
||||
# WIRING (does mosaic-link-runtime-assets call out correctly? does it
|
||||
# propagate a degraded outcome? does it still copy every other runtime file?
|
||||
# does --allow-inactive-enforcement forward through?) is exercised
|
||||
# independently of the TS guard's own logic (already covered by
|
||||
# install-ordering-guard.spec.ts). It also exercises the no-mosaic-on-PATH
|
||||
# python3 fallback directly.
|
||||
#
|
||||
# Scenarios:
|
||||
# 1. probe=true (fake mosaic exits 0) -> settings.json copied, script exits 0.
|
||||
# 2. probe=false (fake mosaic exits 1) -> script exits 1 (guard_degraded
|
||||
# propagated), but every OTHER runtime file is still copied.
|
||||
# 3. probe=false + --allow-inactive-enforcement -> the flag is forwarded to
|
||||
# the fake mosaic stub.
|
||||
# 4. No `mosaic` on PATH at all (activation unconfirmable) -> the python3
|
||||
# fallback strips the enforcement hooks itself and the script exits 1.
|
||||
# 5. No `mosaic` on PATH + --allow-inactive-enforcement -> the python3
|
||||
# fallback wires the hooks AS-IS and the script exits 0.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LINK_SCRIPT="$SCRIPT_DIR/mosaic-link-runtime-assets"
|
||||
|
||||
TMP_ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT
|
||||
|
||||
fail=0
|
||||
fail_msg() {
|
||||
echo "FAIL: $*" >&2
|
||||
fail=1
|
||||
}
|
||||
|
||||
FIXTURE_SETTINGS='{
|
||||
"model": "opus",
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{ "matcher": ".*", "hooks": [ { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude" } ] },
|
||||
{ "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh" } ] }
|
||||
],
|
||||
"Stop": [
|
||||
{ "hooks": [
|
||||
{ "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude" },
|
||||
{ "type": "command", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}'
|
||||
|
||||
# Sets up a fresh $MOSAIC_HOME/runtime/claude/{settings.json,CLAUDE.md,
|
||||
# hooks-config.json,context7-integration.md} + fresh $HOME, echoes both paths
|
||||
# space-separated for the caller to `read`.
|
||||
new_scenario_dirs() {
|
||||
local scenario="$1"
|
||||
local base="$TMP_ROOT/$scenario"
|
||||
local mosaic_home="$base/mosaic-home"
|
||||
local home="$base/home"
|
||||
mkdir -p "$mosaic_home/runtime/claude" "$home"
|
||||
printf '%s' "$FIXTURE_SETTINGS" > "$mosaic_home/runtime/claude/settings.json"
|
||||
echo "claude.md fixture" > "$mosaic_home/runtime/claude/CLAUDE.md"
|
||||
echo '{"hooks":{}}' > "$mosaic_home/runtime/claude/hooks-config.json"
|
||||
echo "context7 fixture" > "$mosaic_home/runtime/claude/context7-integration.md"
|
||||
echo "$mosaic_home" "$home"
|
||||
}
|
||||
|
||||
settings_has_marker() {
|
||||
local file="$1" marker="$2"
|
||||
[[ -f "$file" ]] && grep -q "$marker" "$file"
|
||||
}
|
||||
|
||||
# A fake `mosaic` binary implementing only the __link-claude-settings contract
|
||||
# this harness needs: writes dest verbatim (fixture is unmodified either way —
|
||||
# this stub only exercises the CALL CONTRACT, not the TS strip logic, which
|
||||
# has its own vitest coverage) and exits with the code the scenario wants.
|
||||
# Records the args it was called with so the harness can assert forwarding.
|
||||
make_fake_mosaic() {
|
||||
local bin_dir="$1" exit_code="$2"
|
||||
mkdir -p "$bin_dir"
|
||||
cat > "$bin_dir/mosaic" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
echo "\$@" > "$bin_dir/mosaic.args"
|
||||
if [[ "\$1" == "__link-claude-settings" ]]; then
|
||||
cp "\$2" "\$3"
|
||||
exit $exit_code
|
||||
fi
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$bin_dir/mosaic"
|
||||
}
|
||||
|
||||
# --- Scenario 1: probe=true (fake mosaic exits 0) ---------------------------
|
||||
read -r MOSAIC_HOME_1 HOME_1 < <(new_scenario_dirs scenario1)
|
||||
BIN_1="$TMP_ROOT/scenario1/bin"
|
||||
make_fake_mosaic "$BIN_1" 0
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_1" HOME="$HOME_1" PATH="$BIN_1:$PATH" "$LINK_SCRIPT" 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "scenario1 (probe=true): expected exit 0, got $STATUS. Output: $OUTPUT"
|
||||
[[ -f "$HOME_1/.claude/settings.json" ]] || fail_msg "scenario1: settings.json was not copied"
|
||||
|
||||
# --- Scenario 2: probe=false (fake mosaic exits 1) --------------------------
|
||||
read -r MOSAIC_HOME_2 HOME_2 < <(new_scenario_dirs scenario2)
|
||||
BIN_2="$TMP_ROOT/scenario2/bin"
|
||||
make_fake_mosaic "$BIN_2" 1
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_2" HOME="$HOME_2" PATH="$BIN_2:$PATH" "$LINK_SCRIPT" 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "scenario2 (probe=false, default): expected non-zero exit, got 0. Output: $OUTPUT"
|
||||
[[ -f "$HOME_2/.claude/CLAUDE.md" ]] || fail_msg "scenario2: CLAUDE.md was NOT copied even though it is independent of the settings.json guard"
|
||||
[[ -f "$HOME_2/.claude/hooks-config.json" ]] || fail_msg "scenario2: hooks-config.json was NOT copied"
|
||||
[[ -f "$HOME_2/.claude/context7-integration.md" ]] || fail_msg "scenario2: context7-integration.md was NOT copied"
|
||||
case "$OUTPUT" in
|
||||
*"NOT be wired"*|*"NOT wired"*) ;;
|
||||
*) fail_msg "scenario2: expected an actionable degraded-wiring message in output, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 3: probe=false + --allow-inactive-enforcement forwards the flag
|
||||
read -r MOSAIC_HOME_3 HOME_3 < <(new_scenario_dirs scenario3)
|
||||
BIN_3="$TMP_ROOT/scenario3/bin"
|
||||
make_fake_mosaic "$BIN_3" 0
|
||||
|
||||
MOSAIC_HOME="$MOSAIC_HOME_3" HOME="$HOME_3" PATH="$BIN_3:$PATH" "$LINK_SCRIPT" --allow-inactive-enforcement >/dev/null 2>&1
|
||||
RECORDED_ARGS="$(cat "$BIN_3/mosaic.args" 2>/dev/null || true)"
|
||||
case "$RECORDED_ARGS" in
|
||||
*"--allow-inactive-enforcement"*) ;;
|
||||
*) fail_msg "scenario3: --allow-inactive-enforcement was not forwarded to the mosaic CLI invocation (got: '$RECORDED_ARGS')" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 4: no `mosaic` on PATH at all -> python3 fallback strips hooks
|
||||
read -r MOSAIC_HOME_4 HOME_4 < <(new_scenario_dirs scenario4)
|
||||
EMPTY_BIN="$TMP_ROOT/scenario4/empty-bin"
|
||||
mkdir -p "$EMPTY_BIN"
|
||||
# A PATH containing only python3 (for the fallback) + core utils, no mosaic.
|
||||
FALLBACK_PATH="$EMPTY_BIN:/usr/bin:/bin"
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_4" HOME="$HOME_4" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "scenario4 (no mosaic on PATH, default): expected non-zero exit, got 0. Output: $OUTPUT"
|
||||
if settings_has_marker "$HOME_4/.claude/settings.json" "mutator-gate.py"; then
|
||||
fail_msg "scenario4: mutator-gate.py hook was wired even though mosaic could not be resolved (activation unconfirmable)"
|
||||
fi
|
||||
if settings_has_marker "$HOME_4/.claude/settings.json" "receipt-observer-client.py"; then
|
||||
fail_msg "scenario4: receipt-observer-client.py hook was wired even though mosaic could not be resolved"
|
||||
fi
|
||||
if ! settings_has_marker "$HOME_4/.claude/settings.json" "prevent-memory-write.sh"; then
|
||||
fail_msg "scenario4: the unrelated prevent-memory-write.sh hook was incorrectly dropped too"
|
||||
fi
|
||||
|
||||
# --- Scenario 5: no `mosaic` on PATH + --allow-inactive-enforcement --------
|
||||
read -r MOSAIC_HOME_5 HOME_5 < <(new_scenario_dirs scenario5)
|
||||
|
||||
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_5" HOME="$HOME_5" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" --allow-inactive-enforcement 2>&1)
|
||||
STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "scenario5 (no mosaic, opt-out): expected exit 0, got $STATUS. Output: $OUTPUT"
|
||||
if ! settings_has_marker "$HOME_5/.claude/settings.json" "mutator-gate.py"; then
|
||||
fail_msg "scenario5: mutator-gate.py hook should have been wired (explicit opt-out set)"
|
||||
fi
|
||||
case "$OUTPUT" in
|
||||
*"WARNING"*"--allow-inactive-enforcement"*) ;;
|
||||
*) fail_msg "scenario5: expected a loud WARNING mentioning --allow-inactive-enforcement, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "install-ordering-guard regression passed (5/5 scenarios)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -70,6 +70,8 @@ Security vulnerability review focusing on:
|
||||
~/.config/mosaic/tools/codex/codex-security-review.sh -n 42
|
||||
```
|
||||
|
||||
PR mode resolves the provider's PR diff rather than relying on the caller's checked-out branch. On Gitea, it fetches the base and `refs/pull/<number>/head` refs and diffs those explicit refs. If the refs cannot be fetched or the resulting diff is empty, the command exits nonzero before Codex runs or a review is posted.
|
||||
|
||||
### Review Against Base Branch
|
||||
|
||||
```bash
|
||||
@@ -253,7 +255,7 @@ Run the script from inside a git repository.
|
||||
|
||||
### "No changes found to review"
|
||||
|
||||
The specified mode (--uncommitted, --base, etc.) found no changes to review.
|
||||
The specified non-PR mode (`--uncommitted`, `--base`, etc.) found no changes to review. PR mode instead fails closed with an actionable error when it cannot construct a non-empty provider diff; verify the PR number, remote, provider login, and ref access before retrying.
|
||||
|
||||
### "Codex produced no output"
|
||||
|
||||
|
||||
@@ -44,38 +44,47 @@ build_diff_context() {
|
||||
diff_text=$(git show "$value" 2>/dev/null)
|
||||
;;
|
||||
pr)
|
||||
# For PRs, we need to fetch the PR diff
|
||||
detect_platform
|
||||
# Provider detection writes its result to stdout; suppress it so it cannot
|
||||
# be mistaken for diff content when this function is used in a substitution.
|
||||
detect_platform >/dev/null
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
diff_text=$(gh pr diff "$value" 2>/dev/null)
|
||||
diff_text=$(gh pr diff "$value" 2>/dev/null) || {
|
||||
echo "Error: Failed to fetch the diff for PR #${value}." >&2
|
||||
return 1
|
||||
}
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
# tea doesn't have a direct pr diff command, use git
|
||||
local pr_base
|
||||
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | grep "^${value}" | awk '{print $2}')
|
||||
if [[ -n "$pr_base" ]]; then
|
||||
diff_text=$(git diff "${pr_base}...HEAD" 2>/dev/null)
|
||||
else
|
||||
# Fallback: fetch PR info via API
|
||||
local repo_info
|
||||
repo_info=$(get_repo_info)
|
||||
local remote_url
|
||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||
local host
|
||||
host=$(echo "$remote_url" | sed -E 's|.*://([^/]+).*|\1|; s|.*@([^:]+).*|\1|')
|
||||
diff_text=$(curl -s "https://${host}/api/v1/repos/${repo_info}/pulls/${value}" \
|
||||
-H "Authorization: token $(tea login list --output simple 2>/dev/null | head -1 | awk '{print $2}')" \
|
||||
2>/dev/null | jq -r '.diff_url // empty')
|
||||
if [[ -n "$diff_text" && "$diff_text" != "null" ]]; then
|
||||
diff_text=$(curl -s "$diff_text" 2>/dev/null)
|
||||
else
|
||||
diff_text=$(git diff "main...HEAD" 2>/dev/null)
|
||||
fi
|
||||
local pr_base base_ref pr_head_ref
|
||||
pr_base=$(tea pr list --fields index,base --output simple 2>/dev/null | awk -v pr="$value" '$1 == pr { print $2; exit }')
|
||||
if [[ -z "$pr_base" ]]; then
|
||||
echo "Error: Could not resolve the base branch for Gitea PR #${value}." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
base_ref="refs/remotes/origin/${pr_base}"
|
||||
pr_head_ref="refs/remotes/origin/pr/${value}/head"
|
||||
if ! git fetch --quiet origin \
|
||||
"+refs/heads/${pr_base}:${base_ref}" \
|
||||
"+refs/pull/${value}/head:${pr_head_ref}"; then
|
||||
echo "Error: Failed to fetch the base and head refs for Gitea PR #${value}." >&2
|
||||
return 1
|
||||
fi
|
||||
diff_text=$(git diff "${base_ref}...${pr_head_ref}") || {
|
||||
echo "Error: Failed to diff the fetched refs for Gitea PR #${value}." >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
echo "Error: Unsupported git platform while resolving PR #${value}." >&2
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "$diff_text"
|
||||
if [[ "$mode" == "pr" && -z "${diff_text//[[:space:]]/}" ]]; then
|
||||
echo "Error: Unable to construct a non-empty diff for PR #${value}; verify the PR refs and provider access." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$diff_text"
|
||||
}
|
||||
|
||||
# Format JSON findings as markdown for PR comments
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/bin/bash
|
||||
# Hermetic regression coverage for Gitea PR diff construction and fail-closed reviews.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
fail() {
|
||||
echo "not ok - $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local haystack="$1" needle="$2"
|
||||
if [[ "$haystack" != *"$needle"* ]]; then
|
||||
printf 'actual output:\n%s\n' "$haystack" >&2
|
||||
fail "expected output to contain: $needle"
|
||||
fi
|
||||
}
|
||||
|
||||
# Prevent CI-provided repository context from leaking into the fixture repositories.
|
||||
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \
|
||||
GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR
|
||||
export GIT_AUTHOR_NAME="Codex Fixture"
|
||||
export GIT_AUTHOR_EMAIL="[email protected]"
|
||||
export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
|
||||
export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
|
||||
export GITEA_LOGIN="fixture"
|
||||
export GITEA_TOKEN="fixture-token"
|
||||
export GITEA_URL="file://$TMP_DIR"
|
||||
|
||||
create_pr_fixture() {
|
||||
local fixture_root="$1" head_mode="$2"
|
||||
local origin="$fixture_root/origin.git"
|
||||
local seed="$fixture_root/seed"
|
||||
local work="$fixture_root/work"
|
||||
local base_sha head_sha
|
||||
|
||||
mkdir -p "$fixture_root"
|
||||
git init --quiet --bare "$origin"
|
||||
git init --quiet --initial-branch=release/next "$seed"
|
||||
printf 'base\n' > "$seed/pr-change.ts"
|
||||
git -C "$seed" add pr-change.ts
|
||||
git -C "$seed" commit --quiet -m "fixture base"
|
||||
base_sha=$(git -C "$seed" rev-parse HEAD)
|
||||
git -C "$seed" remote add origin "$origin"
|
||||
git -C "$seed" push --quiet origin release/next
|
||||
git --git-dir="$origin" symbolic-ref HEAD refs/heads/release/next
|
||||
|
||||
if [[ "$head_mode" == "changed" ]]; then
|
||||
git -C "$seed" switch --quiet -c feature/pr-795
|
||||
printf 'actual-pr-change\n' > "$seed/pr-change.ts"
|
||||
git -C "$seed" commit --quiet -am "fixture PR head"
|
||||
head_sha=$(git -C "$seed" rev-parse HEAD)
|
||||
git -C "$seed" push --quiet origin HEAD:refs/pull/795/head
|
||||
else
|
||||
head_sha="$base_sha"
|
||||
git --git-dir="$origin" update-ref refs/pull/795/head "$head_sha"
|
||||
fi
|
||||
|
||||
# Gitea's provider-owned PR head ref now exists in the local bare origin.
|
||||
git clone --quiet "$origin" "$work"
|
||||
printf '%s\n' "$work"
|
||||
}
|
||||
|
||||
FAKE_BIN="$TMP_DIR/bin"
|
||||
mkdir -p "$FAKE_BIN"
|
||||
cat > "$FAKE_BIN/tea" <<'STUB'
|
||||
#!/bin/bash
|
||||
if [[ "$*" == "pr list --fields index,base --output simple" ]]; then
|
||||
printf '795 release/next\n'
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
STUB
|
||||
cat > "$FAKE_BIN/codex" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'CODEX %s\n' "$*" >> "$CODEX_LOG"
|
||||
exit 99
|
||||
STUB
|
||||
chmod +x "$FAKE_BIN/tea" "$FAKE_BIN/codex"
|
||||
export PATH="$FAKE_BIN:$PATH"
|
||||
|
||||
# The valid fixture is a fresh clone on the non-main base. The PR head exists only
|
||||
# at refs/pull/795/head, so local HEAD cannot accidentally satisfy the assertion.
|
||||
if [[ "${1:-all}" != "fail-closed" ]]; then
|
||||
VALID_WORK=$(create_pr_fixture "$TMP_DIR/valid" changed)
|
||||
(
|
||||
cd "$VALID_WORK"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
diff_context=$(build_diff_context pr 795)
|
||||
assert_contains "$diff_context" "actual-pr-change"
|
||||
|
||||
base_sha=$(git rev-parse refs/remotes/origin/release/next)
|
||||
head_sha=$(git rev-parse refs/remotes/origin/pr/795/head)
|
||||
local_sha=$(git rev-parse HEAD)
|
||||
[[ "$local_sha" == "$base_sha" ]] || fail "fixture clone is not on the PR base"
|
||||
[[ "$head_sha" != "$base_sha" ]] || fail "fixture PR head does not differ from its base"
|
||||
git show-ref --verify --quiet refs/remotes/origin/pr/795/head || \
|
||||
fail "fetched PR head ref is missing"
|
||||
[[ "$(git diff --name-only "${base_sha}...${head_sha}")" == "pr-change.ts" ]] || \
|
||||
fail "explicit PR refs do not contain the fixture change"
|
||||
if git show-ref --verify --quiet refs/heads/main || \
|
||||
git show-ref --verify --quiet refs/remotes/origin/main; then
|
||||
fail "fixture unexpectedly contains a main ref"
|
||||
fi
|
||||
)
|
||||
echo "ok - Gitea PR mode fetches and diffs explicit non-main base and PR head refs"
|
||||
fi
|
||||
|
||||
# Build an empty PR entirely inside another local repository. Both review wrappers
|
||||
# must emit the PR-numbered error before Codex or the stubbed post path can execute.
|
||||
if [[ "${1:-all}" != "pr-head" ]]; then
|
||||
EMPTY_WORK=$(create_pr_fixture "$TMP_DIR/empty" empty)
|
||||
SANDBOX="$TMP_DIR/sandbox"
|
||||
mkdir -p "$SANDBOX/tools/codex/schemas" "$SANDBOX/tools/git"
|
||||
cp "$SCRIPT_DIR/common.sh" \
|
||||
"$SCRIPT_DIR/codex-code-review.sh" \
|
||||
"$SCRIPT_DIR/codex-security-review.sh" \
|
||||
"$SANDBOX/tools/codex/"
|
||||
cp "$SCRIPT_DIR/schemas/code-review-schema.json" \
|
||||
"$SCRIPT_DIR/schemas/security-review-schema.json" \
|
||||
"$SANDBOX/tools/codex/schemas/"
|
||||
cp "$SCRIPT_DIR/../git/detect-platform.sh" "$SANDBOX/tools/git/"
|
||||
|
||||
cat > "$SANDBOX/tools/git/pr-review.sh" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf 'POST %s\n' "$*" >> "$POST_LOG"
|
||||
STUB
|
||||
chmod +x "$SANDBOX/tools/git/pr-review.sh"
|
||||
|
||||
POST_LOG="$TMP_DIR/post.log"
|
||||
CODEX_LOG="$TMP_DIR/codex.log"
|
||||
export POST_LOG CODEX_LOG
|
||||
|
||||
for review_kind in code security; do
|
||||
: > "$POST_LOG"
|
||||
: > "$CODEX_LOG"
|
||||
review_script="$SANDBOX/tools/codex/codex-${review_kind}-review.sh"
|
||||
set +e
|
||||
(
|
||||
cd "$EMPTY_WORK"
|
||||
"$review_script" -n 795
|
||||
) >"$TMP_DIR/${review_kind}.stdout" 2>"$TMP_DIR/${review_kind}.stderr"
|
||||
review_status=$?
|
||||
set -e
|
||||
|
||||
stderr_text=$(cat "$TMP_DIR/${review_kind}.stderr")
|
||||
[[ "$review_status" -ne 0 ]] || fail "${review_kind} review returned success for an empty PR diff"
|
||||
[[ ! -s "$CODEX_LOG" ]] || fail "Codex ran for an empty ${review_kind} PR diff"
|
||||
[[ ! -s "$POST_LOG" ]] || fail "${review_kind} review auto-post ran for an empty PR diff"
|
||||
assert_contains "$stderr_text" "Error:"
|
||||
assert_contains "$stderr_text" "PR #795"
|
||||
echo "ok - empty ${review_kind} PR diff fails closed before Codex and auto-post"
|
||||
done
|
||||
fi
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_NAME=${MOSAIC_AGENT_NAME:-}
|
||||
RUNTIME=${MOSAIC_AGENT_RUNTIME:-}
|
||||
MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||
REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||
TOOL_POLICY=${MOSAIC_AGENT_TOOL_POLICY:-}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || { echo 'ERROR: MOSAIC_AGENT_NAME is required' >&2; exit 64; }
|
||||
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo 'ERROR: invalid agent name' >&2; exit 64; }
|
||||
[ "$RUNTIME" = 'pi' ] || { echo 'ERROR: invalid runtime policy' >&2; exit 64; }
|
||||
[ "$MODEL" = 'openai/gpt-5.6-sol' ] || { echo 'ERROR: invalid model policy' >&2; exit 64; }
|
||||
[ "$REASONING" = 'high' ] || { echo 'ERROR: invalid reasoning policy' >&2; exit 64; }
|
||||
[ "$TOOL_POLICY" = 'operator-interaction' ] || { echo 'ERROR: invalid tool policy' >&2; exit 64; }
|
||||
|
||||
printf '{"agentName":"%s","runtime":"%s","model":"%s","reasoning":"%s","toolPolicy":"%s"}\n' \
|
||||
"$AGENT_NAME" "$RUNTIME" "$MODEL" "$REASONING" "$TOOL_POLICY"
|
||||
@@ -1,30 +1,207 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
||||
# Absent socket ⇒ the LITERAL default tmux socket (no -L). The roster's
|
||||
# socket_name is honored when set; absent never silently becomes mosaic-fleet
|
||||
# (spawn stays consistent with the onboarding cheat-sheet + fleet ps observe).
|
||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
||||
MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15}
|
||||
# FCM-M2-001 boundary: only a roster-derived .env.generated projection and a
|
||||
# separately parsed data-only .env.local can influence launch. Never source an
|
||||
# environment file and never accept a command string from either file.
|
||||
|
||||
if [ -z "$AGENT_NAME" ]; then
|
||||
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
|
||||
MODE=launch
|
||||
case "${1:-}" in
|
||||
--stop)
|
||||
MODE=stop
|
||||
AGENT_NAME=${2:-}
|
||||
;;
|
||||
--interaction)
|
||||
MODE=interaction
|
||||
AGENT_NAME=${2:-}
|
||||
;;
|
||||
*) AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}} ;;
|
||||
esac
|
||||
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 64
|
||||
fi
|
||||
}
|
||||
|
||||
hash_value() {
|
||||
printf '%s' "$1" | sha256sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
fail_env() {
|
||||
local code="$1"
|
||||
local key="$2"
|
||||
local value="$3"
|
||||
echo "ERROR: agent environment rejected: code=${code} key=${key} sha256=$(hash_value "$value")" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
safe_agent_name() {
|
||||
[[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]
|
||||
}
|
||||
|
||||
safe_policy_name() {
|
||||
[[ "$1" =~ ^[a-z][a-z0-9-]*$ ]]
|
||||
}
|
||||
|
||||
safe_path() {
|
||||
[[ "$1" == /* ]] || return 1
|
||||
[[ "$1" != *".."* ]] || return 1
|
||||
[[ ! "$1" =~ [[:space:]\"\'\`\$\\\;\|\&\<\>\(\)\{\}] ]]
|
||||
}
|
||||
|
||||
assert_private_regular_file() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] && [ ! -L "$file" ] || fail_env unsafe-file '(file)' "$file"
|
||||
local mode
|
||||
mode=$(stat -c '%a' -- "$file") || fail_env unsafe-file '(file)' "$file"
|
||||
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(file)' "$file"
|
||||
}
|
||||
|
||||
assert_managed_directory() {
|
||||
local directory="$1"
|
||||
[ -d "$directory" ] && [ ! -L "$directory" ] || fail_env unsafe-directory '(directory)' "$directory"
|
||||
local mode
|
||||
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
|
||||
(( (8#$mode & 8#022) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
|
||||
}
|
||||
|
||||
assert_private_directory() {
|
||||
local directory="$1"
|
||||
assert_managed_directory "$directory"
|
||||
local mode
|
||||
mode=$(stat -c '%a' -- "$directory") || fail_env unsafe-directory '(directory)' "$directory"
|
||||
(( (8#$mode & 8#077) == 0 )) || fail_env unsafe-permissions '(directory)' "$directory"
|
||||
}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || fail "agent name argument or MOSAIC_AGENT_NAME is required"
|
||||
safe_agent_name "$AGENT_NAME" || fail_env unsafe-agent-name MOSAIC_AGENT_NAME "$AGENT_NAME"
|
||||
safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
|
||||
|
||||
FLEET_DIR="$MOSAIC_HOME/fleet"
|
||||
AGENT_ENV_DIR="$FLEET_DIR/agents"
|
||||
assert_managed_directory "$MOSAIC_HOME"
|
||||
assert_managed_directory "$FLEET_DIR"
|
||||
assert_private_directory "$AGENT_ENV_DIR"
|
||||
|
||||
GENERATED_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.generated"
|
||||
LOCAL_ENV="$AGENT_ENV_DIR/$AGENT_NAME.env.local"
|
||||
|
||||
declare -A GENERATED_VALUES=()
|
||||
declare -A LOCAL_VALUES=()
|
||||
declare -A SEEN_KEYS=()
|
||||
|
||||
is_sensitive_key() {
|
||||
[[ "$1" =~ (API[_-]?KEY|AUTH|CREDENTIAL|PASSWORD|PRIVATE|SECRET|TOKEN) ]]
|
||||
}
|
||||
|
||||
is_generated_key() {
|
||||
case "$1" in
|
||||
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_local_key() {
|
||||
case "$1" in
|
||||
MOSAIC_RUNTIME_BIN|MOSAIC_HEARTBEAT_RUN_DIR|MOSAIC_HEARTBEAT_INTERVAL|MOSAIC_CLAUDE_JSON|CLAUDE_CONFIG_DIR) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_generated_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
case "$key" in
|
||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
||||
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
||||
MOSAIC_AGENT_RUNTIME)
|
||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
||||
;;
|
||||
MOSAIC_AGENT_MODEL) [[ "$value" =~ ^[A-Za-z0-9._/:+-]*$ ]] || fail_env unsafe-model "$key" "$value" ;;
|
||||
MOSAIC_AGENT_REASONING)
|
||||
case "$value" in ''|low|medium|high) ;; *) fail_env unsupported-reasoning "$key" "$value" ;; esac
|
||||
;;
|
||||
MOSAIC_AGENT_TOOL_POLICY) [ -z "$value" ] || safe_policy_name "$value" || fail_env unsafe-tool-policy "$key" "$value" ;;
|
||||
MOSAIC_AGENT_WORKDIR) safe_path "$value" || fail_env unsafe-path "$key" "$value" ;;
|
||||
MOSAIC_TMUX_SOCKET) [[ "$value" =~ ^[A-Za-z0-9_.-]*$ ]] || fail_env unsafe-socket "$key" "$value" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
validate_local_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
if [ "$key" = MOSAIC_HEARTBEAT_INTERVAL ]; then
|
||||
[[ "$value" =~ ^[1-9][0-9]*$ ]] || fail_env invalid-interval "$key" "$value"
|
||||
else
|
||||
safe_path "$value" || fail_env unsafe-path "$key" "$value"
|
||||
fi
|
||||
}
|
||||
|
||||
load_environment_file() {
|
||||
local file="$1"
|
||||
local kind="$2"
|
||||
[ -e "$file" ] || {
|
||||
[ "$kind" = generated ] && fail_env missing-file '(generated)' "$file"
|
||||
return 0
|
||||
}
|
||||
assert_private_regular_file "$file"
|
||||
SEEN_KEYS=()
|
||||
|
||||
local line key value
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
[ -z "$line" ] && continue
|
||||
if [[ ! "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]]; then
|
||||
fail_env malformed-line '(malformed)' "$line"
|
||||
fi
|
||||
key=${BASH_REMATCH[1]}
|
||||
value=${BASH_REMATCH[2]}
|
||||
[ -z "${SEEN_KEYS[$key]+set}" ] || fail_env duplicate-key "$key" "$value"
|
||||
SEEN_KEYS[$key]=1
|
||||
is_sensitive_key "$key" && fail_env sensitive-key "$key" "$value"
|
||||
|
||||
if [ "$kind" = generated ]; then
|
||||
is_generated_key "$key" || fail_env unknown-key "$key" "$value"
|
||||
validate_generated_value "$key" "$value"
|
||||
GENERATED_VALUES[$key]=$value
|
||||
else
|
||||
is_generated_key "$key" && fail_env generated-key-shadow "$key" "$value"
|
||||
is_local_key "$key" || fail_env unknown-key "$key" "$value"
|
||||
validate_local_value "$key" "$value"
|
||||
LOCAL_VALUES[$key]=$value
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
load_environment_file "$GENERATED_ENV" generated
|
||||
for required_key in \
|
||||
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
||||
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
||||
done
|
||||
load_environment_file "$LOCAL_ENV" local
|
||||
|
||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
||||
|
||||
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
||||
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=${LOCAL_VALUES[MOSAIC_HEARTBEAT_RUN_DIR]:-$MOSAIC_HOME/fleet/run}
|
||||
MOSAIC_HEARTBEAT_INTERVAL=${LOCAL_VALUES[MOSAIC_HEARTBEAT_INTERVAL]:-15}
|
||||
MOSAIC_CLAUDE_JSON=${LOCAL_VALUES[MOSAIC_CLAUDE_JSON]:-}
|
||||
CLAUDE_CONFIG_DIR=${LOCAL_VALUES[CLAUDE_CONFIG_DIR]:-}
|
||||
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux is required" >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
# tmux wrapper: pass -L only when a socket is configured. An absent/empty socket
|
||||
# means the default tmux socket (no -L), keeping spawn == observe == cheat-sheet.
|
||||
_tmux() {
|
||||
if [ -n "$MOSAIC_TMUX_SOCKET" ]; then
|
||||
tmux -L "$MOSAIC_TMUX_SOCKET" "$@"
|
||||
@@ -33,139 +210,90 @@ _tmux() {
|
||||
fi
|
||||
}
|
||||
|
||||
assert_owned_tmux_server() {
|
||||
local owner_file="$MOSAIC_HOME/fleet/run/holder-owner"
|
||||
[ -f "$owner_file" ] && [ ! -L "$owner_file" ] || fail "private tmux ownership identity is missing"
|
||||
local owner_mode
|
||||
owner_mode=$(stat -c '%a' -- "$owner_file") || fail "private tmux ownership identity is unreadable"
|
||||
(( (8#$owner_mode & 8#077) == 0 )) || fail "private tmux ownership identity has unsafe permissions"
|
||||
local owner
|
||||
owner=$(tr -d '\n' < "$owner_file")
|
||||
[[ "$owner" =~ ^[a-f0-9-]{36}$ ]] || fail "private tmux ownership identity is malformed"
|
||||
_tmux has-session -t '=_holder:0.0' 2>/dev/null || fail "owned tmux holder session is absent"
|
||||
local environment expected
|
||||
environment=$(_tmux show-environment -g 2>/dev/null) || fail "owned tmux global environment is unreadable"
|
||||
expected=$(printf '%s\n' \
|
||||
"HOME=$HOME" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=$HOME" \
|
||||
"MOSAIC_FLEET_OWNER=$owner" \
|
||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
|
||||
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
|
||||
fail "tmux server ownership or environment validation failed"
|
||||
}
|
||||
|
||||
# Validate exact server ownership before querying, cleaning, or creating any
|
||||
# managed session. An unmanaged or contaminated named socket is never repaired.
|
||||
assert_owned_tmux_server
|
||||
|
||||
if [ "$MODE" = interaction ]; then
|
||||
[ "$MOSAIC_AGENT_RUNTIME" = pi ] || fail "operator interaction service requires runtime pi"
|
||||
[ "$MOSAIC_AGENT_MODEL" = openai/gpt-5.6-sol ] || \
|
||||
fail "operator interaction service requires the pinned model"
|
||||
[ "$MOSAIC_AGENT_REASONING" = high ] || \
|
||||
fail "operator interaction service requires high reasoning"
|
||||
[ "$MOSAIC_AGENT_TOOL_POLICY" = operator-interaction ] || \
|
||||
fail "operator interaction service requires the operator-interaction tool policy"
|
||||
fi
|
||||
|
||||
if [ "$MODE" = stop ]; then
|
||||
_tmux kill-session -t "=${AGENT_NAME}" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||
echo "Mosaic agent session already running: $AGENT_NAME on socket ${MOSAIC_TMUX_SOCKET:-(default)}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
||||
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
||||
fi
|
||||
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
||||
# that home. Derive the pane home from the canonical path when available so an
|
||||
# inherited pane/session HOME cannot become runtime authority.
|
||||
PANE_HOME=$HOME
|
||||
case "$MOSAIC_HOME" in
|
||||
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
||||
esac
|
||||
|
||||
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
||||
# Precedence:
|
||||
# 1. $MOSAIC_RUNTIME_BIN (explicit override)
|
||||
# 2. $(npm config get prefix)/bin (if npm is on PATH)
|
||||
# 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin
|
||||
#
|
||||
# Only directories that already exist are included. The prefix is baked into
|
||||
# the pane command regardless of what the LAUNCHER process's $PATH contains,
|
||||
# because the tmux pane inherits the tmux SERVER environment (not this script's
|
||||
# environment). A dir on the launcher's PATH may be absent from the server PATH,
|
||||
# so every existing candidate must always be included. Dedup within the
|
||||
# constructed prefix avoids listing the same dir twice.
|
||||
_build_runtime_bin_prefix() {
|
||||
local candidates=()
|
||||
|
||||
if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then
|
||||
candidates+=("$MOSAIC_RUNTIME_BIN")
|
||||
fi
|
||||
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||
if [ -n "$npm_prefix" ]; then
|
||||
candidates+=("${npm_prefix}/bin")
|
||||
fi
|
||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
||||
fi
|
||||
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
|
||||
|
||||
candidates+=("$HOME/.npm-global/bin")
|
||||
candidates+=("$HOME/.local/bin")
|
||||
|
||||
local prefix=""
|
||||
local prefix="" dir
|
||||
for dir in "${candidates[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
if [ -z "$prefix" ]; then
|
||||
prefix="$dir"
|
||||
else
|
||||
case ":${prefix}:" in
|
||||
*":${dir}:"*) ;; # already in our prefix — skip
|
||||
*) prefix="${prefix}:${dir}" ;;
|
||||
esac
|
||||
fi
|
||||
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
||||
done
|
||||
|
||||
printf '%s' "$prefix"
|
||||
}
|
||||
|
||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
||||
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# ── Build the pane command ────────────────────────────────────────────────────
|
||||
# The pane command must:
|
||||
# - Export the augmented PATH so the runtime binary is found.
|
||||
# - exec the agent command so the runtime is the pane's foreground process
|
||||
# (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive).
|
||||
#
|
||||
# Quoting strategy: single-quote the inner shell snippet so that variable
|
||||
# references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside
|
||||
# the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded
|
||||
# NOW (in this script) because the pane shell inherits the tmux server
|
||||
# environment, not this script's env.
|
||||
#
|
||||
# We build the snippet as a double-quoted here-string embedded in a printf call
|
||||
# to avoid nested quoting problems.
|
||||
#
|
||||
# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux
|
||||
# server environment (not this script's, and not the systemd unit's), so the
|
||||
# name would otherwise be empty in-pane and the runtime's native heartbeat
|
||||
# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a
|
||||
# safe single bash token regardless of the name's characters.
|
||||
AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
|
||||
|
||||
# MOSAIC_AGENT_CLASS must ALSO be exported INTO the pane, for the same reason as
|
||||
# MOSAIC_AGENT_NAME above: the pane inherits the tmux SERVER environment (not this
|
||||
# script's env, and not the systemd unit's EnvironmentFile), so the per-agent class
|
||||
# written to agents/<name>.env would otherwise be invisible in-pane. The launcher
|
||||
# composes the persona contract from process.env.MOSAIC_AGENT_CLASS at launch
|
||||
# (compose-contract -> readPersonaContractBlock); without this export it sees an
|
||||
# undefined class and silently injects NO persona contract. %q-quote it so it is a
|
||||
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
|
||||
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
|
||||
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
|
||||
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
else
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
fi
|
||||
|
||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||
|
||||
# ── Pre-trust the workdir for the Claude runtime ─────────────────────────────
|
||||
# Claude Code shows a one-time "Is this a project you trust?" folder-trust gate
|
||||
# the first time it opens a directory. A fleet-launched agent has no human to
|
||||
# answer it, so the pane stalls forever at the prompt while its heartbeat keeps
|
||||
# reporting "healthy" (the pane process IS alive — it's just blocked).
|
||||
#
|
||||
# IMPORTANT: --dangerously-skip-permissions does NOT bypass this gate, and
|
||||
# neither does `trustedProjectDirectories` in settings.json (verified empirically
|
||||
# 2026-06-24). The ONLY thing the gate honors is the per-project record in
|
||||
# ~/.claude.json: projects["<dir>"].hasTrustDialogAccepted == true (exactly what
|
||||
# answering the prompt writes). So we pre-seed that record here.
|
||||
#
|
||||
# Idempotent, atomic, best-effort: any failure is non-fatal (the agent still
|
||||
# launches — worst case it stalls on the gate, i.e. the pre-fix status quo).
|
||||
# Only the claude runtime needs this; codex/pi have no such gate.
|
||||
_ensure_claude_workdir_trusted() {
|
||||
local workdir="$1"
|
||||
# The path claude keys on is the resolved cwd it is launched in.
|
||||
local rp
|
||||
rp=$(cd "$workdir" 2>/dev/null && pwd -P) || rp="$workdir"
|
||||
# ~/.claude.json lives next to the claude config dir; honor CLAUDE_CONFIG_DIR.
|
||||
local resolved
|
||||
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
||||
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "WARNING: python3 not found; cannot pre-trust '$rp' for claude (agent may stall on the folder-trust gate)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Serialize concurrent agent launches that share ~/.claude.json (flock if available).
|
||||
local lock="${claude_json}.mosaic-lock"
|
||||
_seed() {
|
||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$rp" python3 - <<'PY'
|
||||
command -v python3 >/dev/null 2>&1 || return 1
|
||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
||||
import json, os, sys, tempfile
|
||||
cj = os.environ["MOSAIC_CJ"]
|
||||
d = os.environ["MOSAIC_TRUST_DIR"]
|
||||
@@ -174,22 +302,19 @@ try:
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except Exception:
|
||||
# Never corrupt an unreadable/partial file — bail without writing.
|
||||
sys.exit(2)
|
||||
projects = data.setdefault("projects", {})
|
||||
entry = projects.get(d)
|
||||
if not isinstance(entry, dict):
|
||||
entry = {}
|
||||
projects[d] = entry
|
||||
if entry.get("hasTrustDialogAccepted") is True:
|
||||
sys.exit(0) # already trusted — nothing to do
|
||||
entry["hasTrustDialogAccepted"] = True
|
||||
tmp_dir = os.path.dirname(cj) or "."
|
||||
fd, tmp = tempfile.mkstemp(dir=tmp_dir, prefix=".claude.json.mosaic.")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp, cj) # atomic
|
||||
os.replace(tmp, cj)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
@@ -197,56 +322,56 @@ except Exception:
|
||||
pass
|
||||
sys.exit(3)
|
||||
PY
|
||||
}
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
( flock 9; _seed ) 9>"$lock" 2>/dev/null || _seed
|
||||
else
|
||||
_seed
|
||||
fi
|
||||
}
|
||||
|
||||
case "$MOSAIC_AGENT_RUNTIME" in
|
||||
claude)
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" \
|
||||
|| echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
;;
|
||||
esac
|
||||
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
|
||||
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
fi
|
||||
|
||||
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ────
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||
|
||||
# The tmux holder owns a named server. Explicitly clear the pane environment
|
||||
# so server/session variables cannot cross the launch boundary; retain only
|
||||
# trusted bootstrap, generated, and approved local data as argv assignments.
|
||||
LAUNCH_ENV=(
|
||||
/usr/bin/env
|
||||
-i
|
||||
"HOME=$PANE_HOME"
|
||||
"PATH=$PANE_PATH"
|
||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
||||
"MOSAIC_AGENT_REASONING=$MOSAIC_AGENT_REASONING"
|
||||
"MOSAIC_AGENT_TOOL_POLICY=$MOSAIC_AGENT_TOOL_POLICY"
|
||||
"MOSAIC_AGENT_WORKDIR=$MOSAIC_AGENT_WORKDIR"
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET"
|
||||
"MOSAIC_HEARTBEAT_RUN_DIR=$MOSAIC_HEARTBEAT_RUN_DIR"
|
||||
)
|
||||
|
||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||
_tmux new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
|
||||
bash -c "$PANE_SHELL_SNIPPET"
|
||||
"${LAUNCH_ENV[@]}" "${LAUNCH_COMMAND[@]}"
|
||||
|
||||
# ── Resolve the pane PID (retry briefly to let the session initialise) ────────
|
||||
PANE_PID=""
|
||||
for _retry in 1 2 3 4 5; do
|
||||
PANE_PID=$(_tmux list-panes \
|
||||
-t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
||||
PANE_PID=$(_tmux list-panes -t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
||||
[ -n "$PANE_PID" ] && break
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# ── Spawn the heartbeat sidecar (detached, best-effort) ──────────────────────
|
||||
# The sidecar writes ~/.config/mosaic/fleet/run/<AGENT>.hb atomically while the
|
||||
# pane process is alive, then exits so the file goes stale (fleet ps shows stale
|
||||
# then PANE=dead). It is runtime-agnostic: it only cares about the pane PID.
|
||||
_start_heartbeat_sidecar() {
|
||||
local agent="$1"
|
||||
local pane_pid="$2"
|
||||
local run_dir="$3"
|
||||
local interval="$4"
|
||||
local agent="$1" pane_pid="$2" run_dir="$3" interval="$4"
|
||||
local hb_file="${run_dir}/${agent}.hb"
|
||||
|
||||
mkdir -p "$run_dir"
|
||||
|
||||
# Write the sidecar as a self-contained bash one-liner so it carries no
|
||||
# references to any variables from this script's environment.
|
||||
local sidecar_script
|
||||
sidecar_script=$(printf \
|
||||
'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \
|
||||
'hb=%q; pid=%q; iv=%q; native="$hb.native"; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do now=$(date +%%s); marker=$(stat -c %%Y -- "$native" 2>/dev/null || true); if [ -z "$marker" ] || [ -L "$native" ] || (( now - marker > iv * 2 + 1 )); then tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; fi; sleep "$iv"; done' \
|
||||
"$hb_file" "$pane_pid" "$interval")
|
||||
|
||||
# setsid + disown ensures the sidecar survives this script exiting.
|
||||
# stderr/stdout go to /dev/null; failures are non-fatal.
|
||||
if command -v setsid >/dev/null 2>&1; then
|
||||
setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
|
||||
else
|
||||
@@ -256,7 +381,6 @@ _start_heartbeat_sidecar() {
|
||||
}
|
||||
|
||||
if [ -n "$PANE_PID" ]; then
|
||||
# Guard: do not let sidecar startup failures abort the launcher (set -e).
|
||||
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
||||
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
||||
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_NAME=${1:-}
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || fail "agent name argument is required"
|
||||
|
||||
# The shared launcher strictly validates the generated/local data boundary
|
||||
# before it applies this interaction service's pinned profile checks.
|
||||
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" --interaction "$AGENT_NAME"
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# A holder may create only the configured named socket. Existing servers are
|
||||
# accepted only when their private install-derived ownership identity, exact
|
||||
# holder session, and complete approved global environment all match.
|
||||
|
||||
MOSAIC_HOME=${MOSAIC_HOME:-$HOME/.config/mosaic}
|
||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-fleet}
|
||||
MOSAIC_TMUX_HOLDER=${MOSAIC_TMUX_HOLDER:-_holder}
|
||||
OWNER_FILE="$MOSAIC_HOME/fleet/run/holder-owner"
|
||||
TMUX_BIN=/usr/bin/tmux
|
||||
|
||||
fail() {
|
||||
echo "ERROR: refusing unmanaged Mosaic tmux server on socket ${MOSAIC_TMUX_SOCKET}: $1" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
[ -x "$TMUX_BIN" ] || fail "tmux binary is unavailable"
|
||||
[ -f "$OWNER_FILE" ] && [ ! -L "$OWNER_FILE" ] || fail "private ownership identity is missing"
|
||||
owner_mode=$(stat -c '%a' -- "$OWNER_FILE") || fail "private ownership identity is unreadable"
|
||||
(( (8#$owner_mode & 8#077) == 0 )) || fail "private ownership identity has unsafe permissions"
|
||||
MOSAIC_FLEET_OWNER=$(tr -d '\n' < "$OWNER_FILE")
|
||||
[[ "$MOSAIC_FLEET_OWNER" =~ ^[a-f0-9-]{36}$ ]] || fail "private ownership identity is malformed"
|
||||
|
||||
_tmux() {
|
||||
"$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" "$@"
|
||||
}
|
||||
|
||||
server_running() {
|
||||
_tmux list-sessions >/dev/null 2>&1
|
||||
}
|
||||
|
||||
assert_owned_server() {
|
||||
_tmux has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || fail "exact holder session is absent"
|
||||
local environment
|
||||
environment=$(_tmux show-environment -g 2>/dev/null) || fail "global environment is unreadable"
|
||||
local expected
|
||||
expected=$(printf '%s\n' \
|
||||
"HOME=$HOME" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=$HOME" \
|
||||
"MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \
|
||||
"MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" | sort)
|
||||
[ "$(printf '%s\n' "$environment" | sort)" = "$expected" ] || \
|
||||
fail "global environment does not match the owned-server contract"
|
||||
}
|
||||
|
||||
if server_running; then
|
||||
assert_owned_server
|
||||
else
|
||||
cd "$HOME" || fail "trusted home is unavailable"
|
||||
# Start the tmux server itself under the approved environment. The holder pane
|
||||
# receives the same closed environment rather than arbitrary server globals.
|
||||
/usr/bin/env -i \
|
||||
"HOME=$HOME" \
|
||||
PATH=/usr/bin:/bin \
|
||||
"MOSAIC_FLEET_OWNER=$MOSAIC_FLEET_OWNER" \
|
||||
"MOSAIC_TMUX_HOLDER=$MOSAIC_TMUX_HOLDER" \
|
||||
"MOSAIC_TMUX_SOCKET=$MOSAIC_TMUX_SOCKET" \
|
||||
"$TMUX_BIN" -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" \
|
||||
/usr/bin/env -i "HOME=$HOME" PATH=/usr/bin:/bin /bin/sh -c 'while true; do sleep 3600; done'
|
||||
fi
|
||||
@@ -3,370 +3,410 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
START="$SCRIPT_DIR/start-agent-session.sh"
|
||||
SOCKET="mosaic-agent-test-$RANDOM-$$"
|
||||
AGENT="agent-$RANDOM"
|
||||
WORKDIR=$(mktemp -d)
|
||||
|
||||
# Keep a single cleanup trap that accumulates resources.
|
||||
CLEANUP_DIRS=("$WORKDIR")
|
||||
CLEANUP_SOCKETS=("$SOCKET")
|
||||
trap '_cleanup' EXIT
|
||||
_cleanup() {
|
||||
for s in "${CLEANUP_SOCKETS[@]:-}"; do
|
||||
tmux -L "$s" kill-server >/dev/null 2>&1 || true
|
||||
done
|
||||
for d in "${CLEANUP_DIRS[@]:-}"; do
|
||||
rm -rf "$d"
|
||||
done
|
||||
}
|
||||
INTERACTION_START="$SCRIPT_DIR/start-interaction-service.sh"
|
||||
ROOT=$(mktemp -d)
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
TMUX_CALLS=$(mktemp)
|
||||
trap 'rm -rf "$ROOT" "$FAKE_BIN" "$TMUX_CALLS"' EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Test 1: basic session creation with workdir check ─────────────────────────
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
"$START" "$AGENT"
|
||||
|
||||
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
|
||||
# Retry: pane_current_path briefly reflects the tmux server's cwd until the pane
|
||||
# process establishes its own cwd (the -c start dir). Poll until it settles.
|
||||
actual_dir=""
|
||||
for _ in $(seq 1 30); do
|
||||
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
|
||||
[ "$actual_dir" = "$WORKDIR" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir (expected $WORKDIR)"
|
||||
|
||||
# ── Test 2: idempotency (duplicate start prints 'already running') ─────────────
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
"$START" "$AGENT" >/tmp/mosaic-start-agent-idempotent.out
|
||||
|
||||
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
|
||||
|
||||
# ── Test 3: runtime-bin PATH prefix is baked into the pane command ────────────
|
||||
#
|
||||
# We capture the command the script would hand to tmux by injecting a fake
|
||||
# 'tmux' shim into PATH. The shim:
|
||||
# - Intercepts 'new-session' calls and records its arguments to a file.
|
||||
# - For 'has-session' calls, exits 1 (session does not exist) so the script
|
||||
# proceeds to launch instead of printing "already running".
|
||||
# - For 'list-panes' calls, returns empty so PANE_PID stays unset and the
|
||||
# heartbeat sidecar is NOT spawned (heartbeat is not the focus of this test;
|
||||
# test 6 and 7 cover that path). This prevents any real-filesystem side
|
||||
# effects or leaked background processes.
|
||||
# - For all other subcommands, exits 0.
|
||||
#
|
||||
# Assertions:
|
||||
# a) 'export PATH=' with the synthetic MOSAIC_RUNTIME_BIN prefix appears.
|
||||
# b) 'exec' appears so the runtime replaces the wrapper shell.
|
||||
# c) MOSAIC_AGENT_COMMAND with flags is forwarded intact.
|
||||
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN=$(mktemp -d)
|
||||
TMUX_ARGS_FILE=$(mktemp)
|
||||
HB_RUN_DIR3=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN" "$FAKE_RUNTIME_BIN" "$HB_RUN_DIR3")
|
||||
|
||||
# Write the fake tmux shim (uses only positional args, no sourced vars).
|
||||
cat > "$FAKE_BIN/tmux" <<SHIM
|
||||
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
# Fake tmux: record new-session args; report has-session as missing.
|
||||
subcmd="\$3" # argv: tmux -L <socket> <subcmd> ...
|
||||
if [ "\$subcmd" = "has-session" ]; then
|
||||
exit 1 # session not found → script will attempt new-session
|
||||
fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE"
|
||||
exit 0
|
||||
fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
set -euo pipefail
|
||||
printf '%s\0' "$@" >> "${MOSAIC_TEST_TMUX_CALLS:?}"
|
||||
args=("$@")
|
||||
index=0
|
||||
if [ "${args[0]:-}" = -L ]; then index=2; fi
|
||||
case "${args[$index]:-}" in
|
||||
has-session)
|
||||
for argument in "${args[@]}"; do
|
||||
[ "$argument" = '=_holder:0.0' ] && exit 0
|
||||
done
|
||||
exit 1
|
||||
;;
|
||||
show-environment)
|
||||
printf '%s\n' \
|
||||
"HOME=${MOSAIC_TEST_HOME:?}" \
|
||||
'PATH=/usr/bin:/bin' \
|
||||
"PWD=${MOSAIC_TEST_HOME:?}" \
|
||||
"MOSAIC_FLEET_OWNER=${MOSAIC_TEST_FLEET_OWNER:?}" \
|
||||
'MOSAIC_TMUX_HOLDER=_holder' \
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-test'
|
||||
exit 0
|
||||
;;
|
||||
list-panes) printf '%s\n' "${MOSAIC_TEST_PANE_PID:-}"; exit 0 ;;
|
||||
new-session)
|
||||
if [ "${MOSAIC_TEST_EXECUTE_PANE:-}" = 1 ]; then
|
||||
for ((index = 0; index < ${#args[@]}; index++)); do
|
||||
if [ "${args[$index]}" = /usr/bin/env ]; then
|
||||
"${args[@]:$index}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
SOCKET3="mosaic-agent-test3-$RANDOM-$$"
|
||||
AGENT3="agent3-$RANDOM"
|
||||
WORKDIR3=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR3")
|
||||
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET3" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_AGENT_CLASS="code" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
|
||||
"$START" "$AGENT3"
|
||||
|
||||
all_args=$(cat "$TMUX_ARGS_FILE" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE"
|
||||
|
||||
echo "--- captured tmux new-session args ---"
|
||||
echo "$all_args"
|
||||
echo "--- end args ---"
|
||||
|
||||
# a) PATH prefix containing FAKE_RUNTIME_BIN must appear.
|
||||
echo "$all_args" | grep -qF "export PATH=" || fail "pane command does not export PATH"
|
||||
echo "$all_args" | grep -qF "$FAKE_RUNTIME_BIN" || fail "pane command does not include MOSAIC_RUNTIME_BIN in PATH prefix"
|
||||
|
||||
# b) exec must appear so the runtime replaces the wrapper shell.
|
||||
echo "$all_args" | grep -qF "exec " || fail "pane command does not use exec"
|
||||
|
||||
# c) Full MOSAIC_AGENT_COMMAND (with flags) must be forwarded.
|
||||
echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \
|
||||
fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact"
|
||||
|
||||
# d) MOSAIC_AGENT_NAME and the per-agent MOSAIC_AGENT_CLASS must BOTH be exported
|
||||
# INTO the pane. The pane inherits the tmux SERVER environment (not this
|
||||
# script's env, nor the systemd unit's EnvironmentFile), so any per-agent var
|
||||
# the launcher needs in-pane must be re-exported in the snippet. CLASS is
|
||||
# load-bearing: the launcher composes the persona contract from
|
||||
# process.env.MOSAIC_AGENT_CLASS, so a missing export silently drops the
|
||||
# persona (regression guard for the A3a pane-propagation gap).
|
||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
|
||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
|
||||
|
||||
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
|
||||
TMUX_ARGS_FILE2=$(mktemp)
|
||||
FAKE_BIN2=$(mktemp -d)
|
||||
HB_RUN_DIR4=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN2" "$HB_RUN_DIR4")
|
||||
|
||||
cat > "$FAKE_BIN2/tmux" <<SHIM2
|
||||
cat > "$FAKE_BIN/mosaic" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2"
|
||||
exit 0
|
||||
set -euo pipefail
|
||||
env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/mosaic"
|
||||
|
||||
write_generated() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
mkdir -p "$home/fleet/agents" "$home/fleet/run"
|
||||
chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run"
|
||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner"
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||
MOSAIC_AGENT_REASONING=high
|
||||
MOSAIC_AGENT_TOOL_POLICY=code
|
||||
MOSAIC_AGENT_WORKDIR=$home/work
|
||||
MOSAIC_TMUX_SOCKET=mosaic-test
|
||||
EOF
|
||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
||||
mkdir -p "$home/work"
|
||||
}
|
||||
|
||||
run_start() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||
MOSAIC_TEST_HOME="$home" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$home" "$START" "$agent"
|
||||
}
|
||||
|
||||
# Valid generated data launches only the fixed runtime argument array. It never
|
||||
# reads an agent-command string or constructs a bash -c pane payload.
|
||||
HOME_VALID="$ROOT/valid"
|
||||
AGENT_VALID="coder0"
|
||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
||||
run_start "$HOME_VALID" "$AGENT_VALID"
|
||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
||||
if echo "$valid_args" | grep -qF 'bash -c'; then
|
||||
fail "launcher constructed a shell command payload"
|
||||
fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
|
||||
# The pane must start through an absolute clean-environment boundary. Its
|
||||
# runtime command remains an argv vector, but no holder/session environment
|
||||
# control variable can pass through the pane command.
|
||||
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
||||
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
||||
|
||||
# The generated-file parent is a security boundary too: even a private regular
|
||||
# file is untrusted if its parent can be replaced or written by another user.
|
||||
# Validation must happen before fake tmux receives even a has-session call.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_UNSAFE_PARENT="$ROOT/unsafe-parent"
|
||||
write_generated "$HOME_UNSAFE_PARENT" "coder-parent"
|
||||
chmod 777 "$HOME_UNSAFE_PARENT/fleet/agents"
|
||||
if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
|
||||
fail "generated file under a world-writable parent was accepted"
|
||||
fi
|
||||
exit 0
|
||||
SHIM2
|
||||
chmod +x "$FAKE_BIN2/tmux"
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||
|
||||
SOCKET4="mosaic-agent-test4-$RANDOM-$$"
|
||||
AGENT4="agent4-$RANDOM"
|
||||
WORKDIR4=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR4")
|
||||
|
||||
# MOSAIC_RUNTIME_BIN points to a non-existent dir so prefix will be empty;
|
||||
# .npm-global/bin and .local/bin may or may not exist but we just want exec.
|
||||
PATH="$FAKE_BIN2:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET4" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR4" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="/nonexistent-dir-$$" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR4" \
|
||||
"$START" "$AGENT4"
|
||||
|
||||
all_args4=$(cat "$TMUX_ARGS_FILE2" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE2"
|
||||
rm -rf "$WORKDIR4"
|
||||
|
||||
echo "$all_args4" | grep -qF "exec " || fail "pane command (no prefix dirs) does not use exec"
|
||||
echo "$all_args4" | grep -qF "mosaic yolo pi" || fail "pane command does not include agent command when no prefix"
|
||||
|
||||
# ── Test 5: candidate dir already in LAUNCHER $PATH is still baked into pane ──
|
||||
#
|
||||
# Regression guard for the bug where _build_runtime_bin_prefix() used to skip
|
||||
# a candidate because it was already present in the launcher process's $PATH.
|
||||
# That check was wrong: the pane inherits the tmux SERVER environment, not the
|
||||
# launcher's env. Even if a dir is on the launcher's PATH it must always be
|
||||
# baked into the pane's PATH export.
|
||||
#
|
||||
# We prove this by setting PATH to include FAKE_RUNTIME_BIN5 (the candidate),
|
||||
# then asserting the generated new-session command still exports it.
|
||||
TMUX_ARGS_FILE5=$(mktemp)
|
||||
FAKE_BIN5=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN5=$(mktemp -d) # this dir IS on the launcher's PATH below
|
||||
HB_RUN_DIR5=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN5" "$FAKE_RUNTIME_BIN5" "$HB_RUN_DIR5")
|
||||
|
||||
cat > "$FAKE_BIN5/tmux" <<SHIM5
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE5"
|
||||
exit 0
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
||||
write_generated "$HOME_SYMLINK_PARENT" "coder-symlink-parent"
|
||||
mv "$HOME_SYMLINK_PARENT/fleet/agents" "$HOME_SYMLINK_PARENT/private-agents"
|
||||
ln -s "$HOME_SYMLINK_PARENT/private-agents" "$HOME_SYMLINK_PARENT/fleet/agents"
|
||||
if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
|
||||
fail "generated file under a symlinked parent was accepted"
|
||||
fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
SHIM5
|
||||
chmod +x "$FAKE_BIN5/tmux"
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||
|
||||
SOCKET5="mosaic-agent-test5-$RANDOM-$$"
|
||||
AGENT5="agent5-$RANDOM"
|
||||
WORKDIR5=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR5")
|
||||
CLEANUP_SOCKETS+=("$SOCKET5")
|
||||
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
||||
# symlink or group/world-writable ancestor must fail before environment parsing,
|
||||
# workdir creation, or tmux effects. The malformed local input proves parsing
|
||||
# was not reached when the ancestor rejection is reported.
|
||||
assert_managed_ancestor_rejected() {
|
||||
local ancestor="$1"
|
||||
local hazard="$2"
|
||||
local home="$ROOT/managed-${ancestor//\//-}-${hazard}"
|
||||
local agent="coder-managed-${ancestor//\//-}-${hazard}"
|
||||
local node
|
||||
write_generated "$home" "$agent"
|
||||
printf 'MOSAIC_AGENT_COMMAND=must-not-be-parsed\n' > "$home/fleet/agents/$agent.env.local"
|
||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||
rm -rf "$home/work"
|
||||
|
||||
# FAKE_RUNTIME_BIN5 is deliberately placed on the LAUNCHER PATH so that the
|
||||
# old (buggy) code would have skipped it. The correct code must still include
|
||||
# it in the pane PATH export.
|
||||
PATH="$FAKE_BIN5:$FAKE_RUNTIME_BIN5:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET5" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR5" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN5" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR5" \
|
||||
"$START" "$AGENT5"
|
||||
case "$ancestor" in
|
||||
MOSAIC_HOME) node="$home" ;;
|
||||
MOSAIC_HOME/fleet) node="$home/fleet" ;;
|
||||
MOSAIC_HOME/fleet/agents) node="$home/fleet/agents" ;;
|
||||
*) fail "unknown managed ancestor: $ancestor" ;;
|
||||
esac
|
||||
|
||||
all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE5"
|
||||
rm -rf "$WORKDIR5"
|
||||
if [ "$hazard" = symlink ]; then
|
||||
local target="${node}-target"
|
||||
mv "$node" "$target"
|
||||
ln -s "$target" "$node"
|
||||
else
|
||||
chmod 777 "$node"
|
||||
fi
|
||||
|
||||
echo "--- test 5: launcher-PATH candidate must still appear in pane export ---"
|
||||
echo "$all_args5"
|
||||
echo "--- end test 5 args ---"
|
||||
: > "$TMUX_CALLS"
|
||||
if output=$(run_start "$home" "$agent" 2>&1); then
|
||||
fail "${hazard} $ancestor was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
||||
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
||||
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
|
||||
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "$all_args5" | grep -qF "export PATH=" || \
|
||||
fail "test5: pane command does not export PATH when candidate is on launcher PATH"
|
||||
echo "$all_args5" | grep -qF "$FAKE_RUNTIME_BIN5" || \
|
||||
fail "test5: candidate dir (already on launcher PATH) was NOT baked into pane PATH — regression"
|
||||
|
||||
# ── Test 6: heartbeat sidecar — pane PID resolved + .hb file written ──────────
|
||||
#
|
||||
# Uses a real tmux session (same socket as test 1 which already has $AGENT) so
|
||||
# list-panes returns a real pane PID. We override MOSAIC_HEARTBEAT_RUN_DIR to
|
||||
# a temp dir and set a 1-second interval, then wait up to 3 s for the .hb file
|
||||
# to appear and check its content.
|
||||
|
||||
HB_RUN_DIR=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$HB_RUN_DIR")
|
||||
|
||||
# Re-use the session+agent created in Test 1 (still alive on $SOCKET / $AGENT).
|
||||
# We need to invoke the script for a NEW agent on the same socket to exercise
|
||||
# the heartbeat path with a real pane PID.
|
||||
AGENT6="agent6-$RANDOM"
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR" \
|
||||
MOSAIC_HEARTBEAT_INTERVAL="1" \
|
||||
"$START" "$AGENT6"
|
||||
|
||||
HB_FILE="$HB_RUN_DIR/${AGENT6}.hb"
|
||||
|
||||
# Wait up to 5 seconds for the heartbeat file to appear.
|
||||
_waited=0
|
||||
until [ -f "$HB_FILE" ] || [ "$_waited" -ge 5 ]; do
|
||||
sleep 0.5
|
||||
_waited=$((_waited + 1))
|
||||
for managed_ancestor in MOSAIC_HOME MOSAIC_HOME/fleet MOSAIC_HOME/fleet/agents; do
|
||||
assert_managed_ancestor_rejected "$managed_ancestor" symlink
|
||||
assert_managed_ancestor_rejected "$managed_ancestor" group-world-writable
|
||||
done
|
||||
|
||||
[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s"
|
||||
# A local file cannot shadow any roster-derived generated key. Validation must
|
||||
# happen before fake tmux receives even a has-session call.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SHADOW="$ROOT/shadow"
|
||||
write_generated "$HOME_SHADOW" "coder1"
|
||||
printf 'MOSAIC_AGENT_RUNTIME=codex\n' > "$HOME_SHADOW/fleet/agents/coder1.env.local"
|
||||
chmod 600 "$HOME_SHADOW/fleet/agents/coder1.env.local"
|
||||
if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
|
||||
fail "generated-key shadow was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF 'codex'; then
|
||||
fail "shadow diagnostic leaked value"
|
||||
fi
|
||||
|
||||
hb_content=$(cat "$HB_FILE")
|
||||
echo "--- test 6: heartbeat file content ---"
|
||||
echo "$hb_content"
|
||||
echo "--- end test 6 ---"
|
||||
# Arbitrary command compatibility is quarantined/rejected as data. Diagnostics
|
||||
# may name the key and hash but must never echo the privileged command text.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_COMMAND="$ROOT/command"
|
||||
write_generated "$HOME_COMMAND" "coder2"
|
||||
COMMAND_VALUE='mosaic yolo codex --dangerous'
|
||||
printf 'MOSAIC_AGENT_COMMAND=%s\n' "$COMMAND_VALUE" > "$HOME_COMMAND/fleet/agents/coder2.env.local"
|
||||
chmod 600 "$HOME_COMMAND/fleet/agents/coder2.env.local"
|
||||
if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
|
||||
fail "arbitrary command override was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
||||
fail "command diagnostic leaked command value"
|
||||
fi
|
||||
|
||||
# Verify required fields are present.
|
||||
echo "$hb_content" | grep -qE '^ts=[0-9]{4}-[0-9]{2}-[0-9]{2}T' || \
|
||||
fail "test6: heartbeat ts field missing or malformed"
|
||||
echo "$hb_content" | grep -qE '^pid=[0-9]+' || \
|
||||
fail "test6: heartbeat pid field missing or malformed"
|
||||
echo "$hb_content" | grep -qF 'status=ok' || \
|
||||
fail "test6: heartbeat status=ok missing"
|
||||
# Group/world-readable local input is not trusted even when its syntax is safe.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_PERMS="$ROOT/perms"
|
||||
write_generated "$HOME_PERMS" "coder3"
|
||||
printf 'MOSAIC_RUNTIME_BIN=/opt/mosaic/bin\n' > "$HOME_PERMS/fleet/agents/coder3.env.local"
|
||||
chmod 644 "$HOME_PERMS/fleet/agents/coder3.env.local"
|
||||
if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
|
||||
fail "world-readable local input was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||
|
||||
# ── Test 7: heartbeat sidecar — targets correct .hb path per agent name ────────
|
||||
#
|
||||
# Uses the fake-tmux shim approach (like tests 3-5) to capture the sidecar
|
||||
# invocation without needing a real session. A fake setsid shim records its
|
||||
# arguments so we can assert the sidecar script targets the expected .hb path
|
||||
# and uses the configured interval.
|
||||
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
||||
# computed PATH only. The pane command itself must not carry loader, shell
|
||||
# control, arbitrary sentinel, or stale bootstrap variables.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_PANE_BOUNDARY="$ROOT/pane-boundary/.config/mosaic"
|
||||
write_generated "$HOME_PANE_BOUNDARY" "coder-pane-boundary"
|
||||
PANE_TRUSTED_HOME="${HOME_PANE_BOUNDARY%/.config/mosaic}"
|
||||
PANE_STALE_HOME="$ROOT/stale-home"
|
||||
PANE_STALE_PATH="$ROOT/stale-bin"
|
||||
PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
|
||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
|
||||
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
|
||||
BASH_ENV="$PANE_BASH_ENV" \
|
||||
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
|
||||
HOME="$PANE_STALE_HOME" \
|
||||
PATH="$PANE_STALE_PATH" \
|
||||
/usr/bin/env -i \
|
||||
"HOME=$PANE_TRUSTED_HOME" \
|
||||
"PATH=$FAKE_BIN:/usr/bin:/bin" \
|
||||
"MOSAIC_HOME=$HOME_PANE_BOUNDARY" \
|
||||
"MOSAIC_TEST_TMUX_CALLS=$TMUX_CALLS" \
|
||||
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||
"$START" coder-pane-boundary
|
||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "pane did not restore trusted HOME"
|
||||
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
|
||||
fail "pane inherited stale HOME"
|
||||
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
|
||||
done
|
||||
|
||||
FAKE_BIN7=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN7=$(mktemp -d)
|
||||
SETSID_ARGS_FILE=$(mktemp)
|
||||
HB_RUN_DIR7=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN7" "$FAKE_RUNTIME_BIN7" "$HB_RUN_DIR7")
|
||||
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
||||
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
||||
fail "pane command did not clear its environment"
|
||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
||||
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "runtime pane did not receive trusted HOME"
|
||||
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
||||
done
|
||||
|
||||
AGENT7="my-fleet-agent-$RANDOM"
|
||||
INTERVAL7="42"
|
||||
write_interaction_generated() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
mkdir -p "$home/fleet/agents" "$home/fleet/run" "$home/work"
|
||||
chmod 700 "$home" "$home/fleet" "$home/fleet/agents" "$home/fleet/run"
|
||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$home/fleet/run/holder-owner"
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_AGENT_CLASS=operator-interaction
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
||||
MOSAIC_AGENT_REASONING=high
|
||||
MOSAIC_AGENT_TOOL_POLICY=operator-interaction
|
||||
MOSAIC_AGENT_WORKDIR=$home/work
|
||||
MOSAIC_TMUX_SOCKET=mosaic-test
|
||||
EOF
|
||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
||||
}
|
||||
|
||||
# Fake tmux: has-session → not found; new-session → ok; list-panes → known PID.
|
||||
cat > "$FAKE_BIN7/tmux" <<SHIM7
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then exit 0; fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then echo "88888"; exit 0; fi
|
||||
exit 0
|
||||
SHIM7
|
||||
chmod +x "$FAKE_BIN7/tmux"
|
||||
run_interaction() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_HOME="$home" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$home" "$INTERACTION_START" "$agent"
|
||||
}
|
||||
|
||||
# Fake setsid: capture the bash -c <script> argument for inspection, then
|
||||
# background an actual bash subshell so disown succeeds in the caller.
|
||||
cat > "$FAKE_BIN7/setsid" <<'SETSID_SHIM'
|
||||
#!/usr/bin/env bash
|
||||
# argv: setsid bash -c <sidecar_script>
|
||||
# Record the full argument list to the capture file, then exit cleanly.
|
||||
printf '%s\0' "$@" > __SETSID_ARGS_FILE__
|
||||
exit 0
|
||||
SETSID_SHIM
|
||||
# Patch the placeholder with the real capture-file path (avoids heredoc expansion issues).
|
||||
sed -i "s|__SETSID_ARGS_FILE__|${SETSID_ARGS_FILE}|g" "$FAKE_BIN7/setsid"
|
||||
chmod +x "$FAKE_BIN7/setsid"
|
||||
write_heartbeat_local() {
|
||||
local home="$1"
|
||||
local agent="$2"
|
||||
mkdir -p "$home/run"
|
||||
cat > "$home/fleet/agents/$agent.env.local" <<EOF
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=$home/run
|
||||
MOSAIC_HEARTBEAT_INTERVAL=1
|
||||
EOF
|
||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||
}
|
||||
|
||||
SOCKET7="mosaic-agent-test7-$RANDOM-$$"
|
||||
WORKDIR7=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR7")
|
||||
wait_for_sidecar_status() {
|
||||
local file="$1"
|
||||
for _retry in $(seq 1 30); do
|
||||
grep -qF 'status=ok' "$file" 2>/dev/null && return 0
|
||||
sleep 0.1
|
||||
done
|
||||
fail "heartbeat sidecar did not resume after native marker became stale or absent"
|
||||
}
|
||||
|
||||
PATH="$FAKE_BIN7:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET7" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR7" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN7" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR7" \
|
||||
MOSAIC_HEARTBEAT_INTERVAL="$INTERVAL7" \
|
||||
"$START" "$AGENT7"
|
||||
# A fresh Pi-native marker is authoritative: the shell sidecar may start but
|
||||
# must not overwrite Pi's busy/ok/model heartbeat. It must resume only when
|
||||
# the marker is stale or absent.
|
||||
HOME_NATIVE_FRESH="$ROOT/native-fresh"
|
||||
write_generated "$HOME_NATIVE_FRESH" "coder-native-fresh"
|
||||
write_heartbeat_local "$HOME_NATIVE_FRESH" "coder-native-fresh"
|
||||
FRESH_HB="$HOME_NATIVE_FRESH/run/coder-native-fresh.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=authoritative-model\n' > "$FRESH_HB"
|
||||
touch "$FRESH_HB.native"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_FRESH" coder-native-fresh
|
||||
sleep 0.3
|
||||
fresh_content=$(cat "$FRESH_HB")
|
||||
[ "$fresh_content" = 'ts=native
|
||||
pid=1
|
||||
status=busy
|
||||
model=authoritative-model' ] || fail "fresh native heartbeat was overwritten"
|
||||
|
||||
# Give the background setsid shim a moment to finish writing the capture file.
|
||||
sleep 0.5
|
||||
HOME_NATIVE_STALE="$ROOT/native-stale"
|
||||
write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
||||
touch -d '10 seconds ago' "$STALE_HB.native"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||
wait_for_sidecar_status "$STALE_HB"
|
||||
|
||||
setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true)
|
||||
rm -f "$SETSID_ARGS_FILE"
|
||||
rm -rf "$WORKDIR7"
|
||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
||||
write_generated "$HOME_NATIVE_ABSENT" "coder-native-absent"
|
||||
write_heartbeat_local "$HOME_NATIVE_ABSENT" "coder-native-absent"
|
||||
ABSENT_HB="$HOME_NATIVE_ABSENT/run/coder-native-absent.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=absent-model\n' > "$ABSENT_HB"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_ABSENT" coder-native-absent
|
||||
wait_for_sidecar_status "$ABSENT_HB"
|
||||
|
||||
echo "--- test 7: captured setsid args ---"
|
||||
echo "$setsid_args"
|
||||
echo "--- end test 7 ---"
|
||||
# The interaction wrapper delegates to the shared strict parser before applying
|
||||
# its pinned policy, so malformed projection data wins over profile diagnostics.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_INTERACTION_MALFORMED="$ROOT/interaction-malformed"
|
||||
write_interaction_generated "$HOME_INTERACTION_MALFORMED" "interaction-malformed"
|
||||
printf 'UNTRUSTED_BOOTSTRAP=value\n' >> "$HOME_INTERACTION_MALFORMED/fleet/agents/interaction-malformed.env.generated"
|
||||
if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed 2>&1); then
|
||||
fail "interaction wrapper accepted malformed generated data"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
||||
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||
|
||||
# The sidecar script (bash -c <script>) must reference the correct .hb path.
|
||||
expected_hb="${HB_RUN_DIR7}/${AGENT7}.hb"
|
||||
echo "$setsid_args" | grep -qF "$expected_hb" || \
|
||||
fail "test7: sidecar script does not reference correct .hb path ($expected_hb)"
|
||||
# A syntactically valid but policy-incompatible projection reaches the pinned
|
||||
# interaction policy check only after strict parsing and never starts tmux.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
||||
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
|
||||
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
||||
fi
|
||||
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
||||
fail "interaction pinned-policy rejection created a tmux session"
|
||||
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
||||
fail "interaction pinned-policy check did not follow strict parsing"
|
||||
|
||||
# The sidecar script must use the configured interval.
|
||||
echo "$setsid_args" | grep -qF "$INTERVAL7" || \
|
||||
fail "test7: sidecar script does not reference configured interval ($INTERVAL7)"
|
||||
# Exact stop derives the socket exclusively from the validated generated
|
||||
# projection and ignores an ambient socket supplied by the caller.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_STOP="$ROOT/stop"
|
||||
write_generated "$HOME_STOP" "coder-stop"
|
||||
HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_HOME="$HOME_STOP" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
||||
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
||||
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
|
||||
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
||||
fail "exact stop trusted an ambient socket"
|
||||
fi
|
||||
|
||||
echo "ok - start-agent-session"
|
||||
echo 'ok - start-agent-session generated environment boundary'
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Git provider wrappers
|
||||
|
||||
These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone, and CI operations.
|
||||
|
||||
## Durable review provenance
|
||||
|
||||
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments, approvals, and change requests count as durable provenance only after the wrapper reads the created provider record back and verifies that it was created by _this_ write.
|
||||
|
||||
**The write is a direct Gitea REST `POST` that returns the created record's id.** Neither wrapper writes through `tea` — tea 0.11.1 can silently no-op while exiting 0 and cannot emit the id of a record it creates, so its exit code is worthless as proof of a durable write (#865). Instead:
|
||||
|
||||
- Comments (`issue-comment.sh`, and the `comment` action of `pr-review.sh`) `POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments`, requiring a `201` and parsing the created comment's `id` from the response body.
|
||||
- Reviews (`approve` / `request-changes`) `POST /api/v1/repos/{owner}/{repo}/pulls/{index}/reviews` with the `event` (`APPROVED` / `REQUEST_CHANGES`), the review `body`, and `commit_id` pinned to the PR's current head, then parse the created review's `id`. The review body travels _in the review submit itself_ — there is no separate detached comment to reconcile (a Gitea `REQUEST_CHANGES` review requires a non-empty body, which the submit carries).
|
||||
|
||||
**Verification keys on that exact provider-returned id.** The wrapper then `GET`s that one record directly — `GET /issues/comments/{id}` or `GET /pulls/{n}/reviews/{id}` — and requires that its `id` equals the created id, its **author login equals the acting identity** (resolved via `GET /api/v1/user` for the token in use), and, for comments, its body exactly matches what was submitted **and its returned web URL belongs to this exact provider and repository** (the `issue_url` / `pull_request_url` origin — scheme, host, and effective port — and full path, i.e. deployment prefix + exact `owner/repo` + kind + number, must match; a suffix/`endsWith` test would accept a look-alike host or a decoy path prefix, so the whole normalized URL is compared). The `comment` action of `pr-review.sh` additionally requires the returned resource be a **pull request** (a populated `pull_request_url`); a bare `issue_url` is rejected, so if issue `#N` exists but PR `#N` does not, an issue comment cannot be reported as a verified PR comment. (`issue-comment.sh` legitimately keeps the broader issue-or-PR acceptance.) For reviews, its state matches the requested action, its reviewed `commit_id` equals the PR head, **and its persisted body equals the submitted body** — an exact, presence- and type-checked equality (a missing/`null` persisted body no longer counts as an empty match), because Gitea can finalize/reuse a pending review id whose stored content was authored elsewhere, so the body is bound too. The write, the `/user` identity lookup, and the read-back all use the **same** credential — the effective login's token, or the host credential when no login is named — so the write is verified against the identity that actually performed it.
|
||||
|
||||
**A review's pinned head is re-checked after verification (current-head TOCTOU).** The `commit_id` is pinned to the PR head read _before_ the submit; between that read and the read-back the branch could advance (a force-push or a new commit), leaving a verified review attached to a now-superseded commit while the live tip carries unreviewed code. After the exact-id read-back succeeds, the wrapper re-reads the live PR head (`GET …/pulls/{n}`) and requires it still equals the submitted SHA; if the head advanced it fails closed (non-zero, no success line) rather than reporting a review that no longer covers the PR's current commit.
|
||||
|
||||
**This closes the concurrency window rather than documenting it.** Because verification keys on the id the create returned, a no-op create yields no id and fails closed with no list-scan fallback, and a _concurrent_ record — even one written by the _same_ identity with an identical body/state — has a _different_ id and cannot be mistaken for this write. There is no residual same-identity window: the earlier boundary-and-author heuristic (accept any `id > pre-write-max` with a matching author) is replaced entirely by exact-id attribution.
|
||||
|
||||
**Exact-id read-back is the sole authority.** Verification is a direct `GET` of the one record the create returned; there is no follow-up list enumeration. An earlier redundant pass that re-listed the record's page (`?limit=&page=1,2,…`) was removed: server-capped page sizes and list-pagination quirks made it a false-failure source (a durable, exact-id-verified record could be missed by a non-exhaustive enumeration), and it added nothing over the authoritative exact-id `GET`.
|
||||
|
||||
## Credential handling
|
||||
|
||||
The Gitea API token is **never passed on a curl command line.** An `Authorization: token <value>` argument would be visible to any local process that can read the process table (`ps` / `/proc/<pid>/cmdline`) for the lifetime of the request. Instead, every authenticated curl call writes the header into a private, mode-`0600` config file under `$TMPDIR` and passes it with `curl --config <file>` (`gitea_write_auth_config`), so only the file _path_ — never the token — appears in argv. Each such file is unlinked on every exit path (success and failure) by the caller's `RETURN` trap.
|
||||
|
||||
## `tea` invocation notes (Gitea)
|
||||
|
||||
- tea v0.11.1 has **no `comment` subcommand under `tea pr` or `tea issue`** — the `tea pr comment` / `tea issue comment` forms don't error, they silently fall through to a no-op and still exit 0, producing a false-success write (#865). tea's write subcommands (`tea comment`, `tea pr approve`/`reject`) also cannot report the id of the record they create, so their exit code cannot prove a durable write. These wrappers therefore do **not** write reviews or comments through `tea` at all; they use direct Gitea REST `POST`s that return the created record's id (see "Durable review provenance" above). `tea` is consulted only to enumerate the login list for host→login resolution.
|
||||
- Because the review body is carried in the `POST …/reviews` submit itself, there is no separate detached review comment, and the historical `tea pr approve`/`reject` trailing-positional-argument vs. nonexistent `--comment`/`-comment` flag hazard (#835) no longer applies to these wrappers — no review comment is ever passed to `tea`.
|
||||
|
||||
### `--login` override
|
||||
|
||||
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
|
||||
|
||||
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
|
||||
|
||||
## Per-agent Gitea identity (Gate-16 author≠reviewer)
|
||||
|
||||
By default, git push/fetch (via `git-credential-mosaic`) and the API wrappers above (via
|
||||
`detect-platform.sh`'s `get_gitea_token`) all authenticate as the single shared Gitea
|
||||
account/token configured through `tools/_lib/credentials.sh`. That means every agent in a
|
||||
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
|
||||
separation between an author and a reviewer.
|
||||
|
||||
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
|
||||
identity** before falling back to the shared account:
|
||||
|
||||
1. `MOSAIC_GIT_IDENTITY` environment variable, or
|
||||
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
|
||||
non-persistent shells — `git config mosaic.gitIdentity <agent-id>`), or
|
||||
3. (git-credential-mosaic only) the username git itself supplies for the credential
|
||||
request.
|
||||
|
||||
If the resolved identity has a token file at
|
||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<agent-id>.token`, that
|
||||
identity + token is used. **Nothing configured → nothing changes**: with no per-slot
|
||||
token file present, both tools fall through to the existing shared-account path
|
||||
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
|
||||
tokens.
|
||||
|
||||
### Enabling it for a clone
|
||||
|
||||
The framework installer syncs `git-credential-mosaic` to
|
||||
`~/.config/mosaic/tools/git/git-credential-mosaic` (executable) on every install/update,
|
||||
but does **not** register it as git's credential helper automatically. Registration is a
|
||||
one-time, explicit step:
|
||||
|
||||
```bash
|
||||
# Per-repo (recommended — scopes the helper to this clone only):
|
||||
git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
|
||||
# Per-worktree identity pin (Gate-16 separation):
|
||||
git config mosaic.gitIdentity <agent-id>
|
||||
```
|
||||
|
||||
This is deliberately **not** auto-registered on install/update: `credential.helper` is
|
||||
global, order-sensitive git config (`~/.gitconfig`) that can already hold an
|
||||
operator-chosen credential manager (keychain, `store`, `manager-core`, …) for
|
||||
repositories unrelated to Mosaic. Silently inserting an entry on every framework
|
||||
install/upgrade risks reordering or shadowing that operator-owned surface across the
|
||||
whole host — the same operator-owned config the installer's manifest system is
|
||||
otherwise careful never to touch. Because identity is already resolved per-worktree
|
||||
(`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone
|
||||
too, so a documented manual step is the right shape here, not a global auto-write.
|
||||
|
||||
### PowerShell parity
|
||||
|
||||
`detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins
|
||||
(`Get-GiteaLoginForHost`), not a raw-token `get_gitea_token`-equivalent function — there
|
||||
is nothing to prepend the identity-resolution block to on the PowerShell side. A native
|
||||
PowerShell git-credential helper is also unnecessary: `git-credential-mosaic` is invoked
|
||||
by git's credential-helper protocol (stdin/stdout), which works identically under Git for
|
||||
Windows' bundled `bash`/`sh` when configured via `credential.helper`, without a `.ps1`
|
||||
counterpart. A `tea`-login-based per-agent identity for the PowerShell wrappers is a
|
||||
separate, larger design (mapping identities to `tea login` profiles) and is out of scope
|
||||
here.
|
||||
@@ -185,6 +185,16 @@ switch ($platform) {
|
||||
$headSha = ($branchPayload.commit.id | Out-String).Trim()
|
||||
}
|
||||
catch {
|
||||
# A not-yet-pushed feature branch has no in-flight pipeline, so the
|
||||
# pre-push queue guard must treat 404 as "queue clear", not crash.
|
||||
$statusCode = $null
|
||||
if ($_.Exception.Response) {
|
||||
$statusCode = [int]$_.Exception.Response.StatusCode
|
||||
}
|
||||
if ($statusCode -eq 404) {
|
||||
Write-Host "[ci-queue-wait] branch $Branch not yet on remote — no in-flight pipeline; queue clear."
|
||||
exit 0
|
||||
}
|
||||
Write-Error "Could not resolve $Branch head SHA from Gitea API."
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
BRANCH="main"
|
||||
BRANCH=""
|
||||
TARGET_REPO=""
|
||||
HEAD_SHA=""
|
||||
TIMEOUT_SEC=900
|
||||
INTERVAL_SEC=15
|
||||
PURPOSE="merge"
|
||||
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||
|
||||
Options:
|
||||
-B, --branch BRANCH Branch head to inspect (default: main)
|
||||
-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
|
||||
-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)
|
||||
@@ -27,63 +31,65 @@ Options:
|
||||
|
||||
Examples:
|
||||
$(basename "$0")
|
||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
||||
$(basename "$0") --purpose push -t 600 -i 10
|
||||
EOF
|
||||
}
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
get_state_from_status_json() {
|
||||
python3 - <<'PY'
|
||||
# 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 '
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("status payload is not an object")
|
||||
except Exception:
|
||||
print("unknown")
|
||||
print("malformed")
|
||||
raise SystemExit(0)
|
||||
|
||||
statuses = payload.get("statuses") or []
|
||||
state = (payload.get("state") or "").lower()
|
||||
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()
|
||||
|
||||
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):
|
||||
continue
|
||||
value = (item.get("status") or item.get("state") or "").lower()
|
||||
if value:
|
||||
values.append(value)
|
||||
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())
|
||||
|
||||
if not values and not state:
|
||||
print("no-status")
|
||||
elif any(v in pending_values for v in values):
|
||||
if any(value in pending_values for value in values) or state in pending_values:
|
||||
print("pending")
|
||||
elif any(v in failure_values for v in values):
|
||||
elif any(value in failure_values for value in values) or state in failure_values:
|
||||
print("terminal-failure")
|
||||
elif values and all(v in success_values for v in values):
|
||||
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||
print("terminal-success")
|
||||
elif not values:
|
||||
print("no-status")
|
||||
else:
|
||||
print("unknown")
|
||||
PY
|
||||
'
|
||||
}
|
||||
|
||||
print_pending_contexts() {
|
||||
python3 - <<'PY'
|
||||
python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
@@ -104,17 +110,61 @@ for item in statuses:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("context") or item.get("name") or "unknown-context"
|
||||
value = (item.get("status") or item.get("state") or "unknown").lower()
|
||||
value = str(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
|
||||
if target:
|
||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
||||
else:
|
||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
||||
suffix = f" ({target})" if target else ""
|
||||
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||
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() {
|
||||
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
|
||||
local owner="$1"
|
||||
local repo="$2"
|
||||
local sha="$3"
|
||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
||||
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"
|
||||
}
|
||||
|
||||
gitea_get_branch_head_sha() {
|
||||
@@ -137,7 +267,21 @@ gitea_get_branch_head_sha() {
|
||||
local branch="$3"
|
||||
local token="$4"
|
||||
local url="https://${host}/api/v1/repos/${repo}/branches/${branch}"
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||
# Capture HTTP status so an absent branch (404) is distinguished from an API
|
||||
# error. A not-yet-pushed feature branch has no in-flight pipeline, so the
|
||||
# pre-push queue guard must treat 404 as "queue clear", not crash.
|
||||
local resp code body
|
||||
resp=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token ${token}" -w $'\n%{http_code}' "$url")
|
||||
code="${resp##*$'\n'}"
|
||||
body="${resp%$'\n'*}"
|
||||
if [[ "$code" == "404" ]]; then
|
||||
echo "__BRANCH_ABSENT__"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$code" != "200" ]]; then
|
||||
return 1
|
||||
fi
|
||||
printf '%s' "$body" | python3 -c '
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
commit = data.get("commit") or {}
|
||||
@@ -160,6 +304,14 @@ 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
|
||||
@@ -192,41 +344,89 @@ 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
|
||||
|
||||
OWNER=$(get_repo_owner)
|
||||
REPO=$(get_repo_name)
|
||||
detect_platform > /dev/null
|
||||
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
|
||||
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
|
||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
||||
exit 1
|
||||
record_cannot_assert "github-cli-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||
record_cannot_assert "branch-head-unavailable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
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 ! 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 $?
|
||||
fi
|
||||
if [[ -z "$HEAD_SHA" ]]; then
|
||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
||||
exit 1
|
||||
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
|
||||
fi
|
||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||
else
|
||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
||||
exit 1
|
||||
record_cannot_assert "unsupported-platform"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
START_TS=$(date +%s)
|
||||
@@ -235,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
||||
while true; do
|
||||
NOW_TS=$(date +%s)
|
||||
if (( NOW_TS > DEADLINE_TS )); then
|
||||
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||
exit 124
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
||||
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
else
|
||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
||||
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
||||
record_cannot_assert "status-provider-unreachable"
|
||||
exit $?
|
||||
fi
|
||||
fi
|
||||
|
||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||
@@ -253,21 +459,24 @@ 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: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
||||
exit 1
|
||||
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
|
||||
fi
|
||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
||||
exit 0
|
||||
exit 3
|
||||
;;
|
||||
terminal-success|terminal-failure|unknown)
|
||||
# Queue guard only blocks on pending/running/queued states.
|
||||
exit 0
|
||||
terminal-failure|malformed|unknown)
|
||||
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
*)
|
||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
||||
exit 0
|
||||
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||
exit 3
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
#!/bin/bash
|
||||
# git-credential-mosaic — git credential helper — resolves Gitea tokens from
|
||||
# the Mosaic credential store at runtime so remote URLs never embed secrets.
|
||||
#
|
||||
# Install (one-time, per clone or globally):
|
||||
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
# # or, fleet-wide: git config --global credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
#
|
||||
# Per-agent Gate-16 identity (author != reviewer separation):
|
||||
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
|
||||
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
|
||||
#
|
||||
# Resolution 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 file, use it instead of the shared account. Backward
|
||||
# compatible: nothing resolvable -> shared token (unchanged behavior).
|
||||
[ "$1" = "get" ] || exit 0
|
||||
host=""; username_in=""
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && break
|
||||
case "$line" in
|
||||
host=*) host=${line#host=};;
|
||||
username=*) username_in=${line#username=};;
|
||||
esac
|
||||
done
|
||||
# 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.
|
||||
ident="$MOSAIC_GIT_IDENTITY"
|
||||
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
|
||||
[ -z "$ident" ] && ident="$username_in"
|
||||
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"
|
||||
if [ -r "$idtok" ]; then
|
||||
echo "username=${ident}"
|
||||
echo "password=$(cat "$idtok")"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
case "$host" in
|
||||
git.uscllc.com) svc=gitea-usc;;
|
||||
git.mosaicstack.dev) svc=gitea-mosaicstack;;
|
||||
*) exit 0;;
|
||||
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
|
||||
# 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),
|
||||
# not from the username string, so any non-empty placeholder works here — this
|
||||
# is deliberately NOT a real account name (framework files must stay
|
||||
# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh).
|
||||
echo "username=${GITEA_USER:-git}"
|
||||
echo "password=$GITEA_TOKEN"
|
||||
@@ -33,7 +33,7 @@ Examples:
|
||||
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
|
||||
$(basename "$0") -i 42 -a @me
|
||||
EOF
|
||||
exit 1
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
#!/bin/bash
|
||||
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
|
||||
# Usage: issue-comment.sh -i <issue_number> -c <comment>
|
||||
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
|
||||
#
|
||||
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
|
||||
# the non-existent `tea issue comment ...` form does not error — tea silently
|
||||
# no-ops and still exits 0, so a caller trusting the exit code believes a
|
||||
# comment was posted when it was not (#865). tea 0.11.1 also cannot reliably
|
||||
# emit the id of a record it created, so an exit code is the ONLY signal it
|
||||
# offers — and that signal is untrustworthy. This script therefore does not
|
||||
# write via tea at all: it POSTs the comment through the Gitea REST API (which
|
||||
# returns the created comment object, including its id), then GETs that exact
|
||||
# id back and fails closed unless it matches. Keying verification to the
|
||||
# provider-returned created id means a concurrent comment cannot masquerade as
|
||||
# this write and a no-op create simply yields no id to verify.
|
||||
#
|
||||
# --login override: the default login is resolved from the local `tea` login
|
||||
# list for this repo's host (get_gitea_login). Pass --login <name> to override
|
||||
# it for this invocation only. The REST write, the /user identity read, and the
|
||||
# read-back are ALL performed with the token of the EFFECTIVE login (the
|
||||
# override when given), so the write and its verification bind to the same
|
||||
# identity — a --login override is never written under one credential and
|
||||
# verified under a different default one.
|
||||
|
||||
set -e
|
||||
|
||||
@@ -10,6 +30,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
# Parse arguments
|
||||
ISSUE_NUMBER=""
|
||||
COMMENT=""
|
||||
LOGIN_OVERRIDE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
@@ -21,12 +42,17 @@ while [[ $# -gt 0 ]]; do
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
LOGIN_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment>"
|
||||
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -i, --issue Issue number (required)"
|
||||
echo " -c, --comment Comment text (required)"
|
||||
echo " -l, --login Override the detected Gitea tea login for this call"
|
||||
echo " -h, --help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
@@ -49,20 +75,273 @@ fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
||||
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
||||
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
||||
#
|
||||
# The token is resolved for the EFFECTIVE login (the --login override when
|
||||
# given, otherwise the detected default) so that the single credential used for
|
||||
# the write ALSO drives the /user identity read and the read-back — write token
|
||||
# and read-back token are the same identity by construction (this is the
|
||||
# credential-ordering fix: a --login override is no longer written under one
|
||||
# credential and verified under a different default one). Falls back to the
|
||||
# host-scoped credential ONLY when NO --login override was supplied (the
|
||||
# best-effort default path). When $2 is "explicit" the login came from a
|
||||
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
||||
# CLOSED rather than silently downgrading the write to the host default
|
||||
# identity — otherwise a caller relying on a dedicated per-role credential would
|
||||
# be told the write succeeded as requested while it was attributed to the shared
|
||||
# default. Returns non-zero (clear stderr) on any resolution failure.
|
||||
gitea_resolve_api_for_login() {
|
||||
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
||||
|
||||
host=$(get_remote_host)
|
||||
if [[ -n "$override_explicit" ]]; then
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
||||
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
||||
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
|
||||
return 1
|
||||
}
|
||||
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
||||
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||
return 1
|
||||
}
|
||||
GITEA_API_ROOT="${configured_url%/}/api/v1"
|
||||
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
|
||||
# The provider WEB base (scheme + host + effective port + any deployment path
|
||||
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
|
||||
# Read-back verification pins the returned URL's origin + path prefix to THIS,
|
||||
# not just a repo/issue suffix.
|
||||
GITEA_WEB_BASE="${configured_url%/}"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Resolve the login of the identity the API token authenticates as (GET
|
||||
# /user). Used to attribute a read-back record to THIS invocation's writer so
|
||||
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
|
||||
# Prints the login on success.
|
||||
gitea_authenticated_login() {
|
||||
local response_file auth_config status
|
||||
|
||||
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX")
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$response_file"
|
||||
echo "Error: could not stage Gitea credential for identity read" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$response_file" "$auth_config"' RETURN
|
||||
|
||||
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
"$GITEA_API_ROOT/user"); then
|
||||
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$status" != "200" ]]; then
|
||||
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
user = json.load(response)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("missing authenticated login")
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(login)
|
||||
PY
|
||||
}
|
||||
|
||||
# Post a comment to a Gitea issue via the supported REST API and verify it
|
||||
# durably against a PROVIDER-RETURNED created id — never trust an exit code
|
||||
# (#865 defect class: tea's non-existent `tea issue comment` no-ops yet exits
|
||||
# 0). The write is a direct POST that returns the created comment object, so we
|
||||
# learn the exact id of THIS write; we then GET that exact id and require
|
||||
# id == created id AND author == acting identity AND exact body AND that it
|
||||
# belongs to this issue. Because verification is keyed to the id the create
|
||||
# returned, a concurrent comment (even same identity, same body) CANNOT
|
||||
# masquerade as this write, and a suppressed/no-op write yields no created id
|
||||
# and fails closed — there is no fallback list scan that a concurrent record
|
||||
# could satisfy. Prints the created comment id on success.
|
||||
#
|
||||
# Args: $1 = issue number, $2 = comment body, $3 = acting identity login.
|
||||
gitea_create_comment_verified() {
|
||||
local issue_number="$1" comment_body="$2" acting_login="$3"
|
||||
local payload write_file readback_file auth_config write_status readback_status created_id
|
||||
|
||||
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
||||
')
|
||||
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-write.XXXXXX")
|
||||
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-getid.XXXXXX")
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$write_file" "$readback_file"
|
||||
echo "Error: could not stage Gitea credential for comment write" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
|
||||
|
||||
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
||||
-X POST \
|
||||
--config "$auth_config" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$GITEA_API_BASE/issues/$issue_number/comments"); then
|
||||
echo "Error: Gitea comment write transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$write_status" != "201" ]]; then
|
||||
echo "Error: Gitea comment write failed with HTTP $write_status (#865: no durable comment created)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
created_id=$(python3 - "$write_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comment = json.load(response)
|
||||
created_id = comment.get("id") if isinstance(comment, dict) else None
|
||||
if not isinstance(created_id, int) or created_id <= 0:
|
||||
raise ValueError("create response carried no positive comment id")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(created_id)
|
||||
PY
|
||||
) || return 1
|
||||
|
||||
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
"$GITEA_API_BASE/issues/comments/$created_id"); then
|
||||
echo "Error: Gitea comment read-back transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$readback_status" != "200" ]]; then
|
||||
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
||||
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
||||
EXPECTED_NUMBER="$issue_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
|
||||
python3 - "$readback_file" <<'PY' || return 1
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comment = json.load(response)
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("response is not a comment object")
|
||||
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
||||
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
slug = os.environ["EXPECTED_REPO_SLUG"]
|
||||
number = os.environ["EXPECTED_NUMBER"]
|
||||
web_base = os.environ["EXPECTED_WEB_BASE"]
|
||||
# Gitea populates WEB (html) URLs here, not API paths. A plain issue comment
|
||||
# carries issue_url = <web_base>/<owner>/<repo>/issues/<n> (pull_request_url
|
||||
# empty); a comment posted to a PR's conversation carries
|
||||
# pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (issue_url empty).
|
||||
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
|
||||
# provider + repo + kind + number — an endswith/suffix test would accept a
|
||||
# look-alike host (evil.example/deceptive/<slug>/issues/N) or a same-host
|
||||
# decoy prefix (/other/<slug>/issues/N), so compare the whole thing.
|
||||
base_origin, base_path = _origin_and_path(web_base)
|
||||
expected_issue_path = f"{base_path}/{slug}/issues/{number}"
|
||||
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
|
||||
|
||||
def _belongs(url, expected_path):
|
||||
if not url:
|
||||
return False
|
||||
origin, path = _origin_and_path(url)
|
||||
return origin == base_origin and path == expected_path
|
||||
|
||||
if comment.get("id") != expected_id:
|
||||
raise ValueError("read-back id does not match the created id")
|
||||
if (comment.get("user") or {}).get("login") != acting_login:
|
||||
raise ValueError("created comment is not authored by the acting identity")
|
||||
if comment.get("body") != expected_body:
|
||||
raise ValueError("created comment body does not match")
|
||||
if not (
|
||||
_belongs(comment.get("issue_url"), expected_issue_path)
|
||||
or _belongs(comment.get("pull_request_url"), expected_pr_path)
|
||||
):
|
||||
raise ValueError("created comment does not belong to this issue on this provider/repo")
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
echo "$created_id"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
# Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
|
||||
# word-splitting) so the comment body — including Markdown backticks, $(...),
|
||||
# and quotes — is passed verbatim and never re-split or shell-evaluated.
|
||||
REPO_SLUG=$(get_repo_slug)
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
|
||||
# Resolve the login this comment should be attributed to: the --login
|
||||
# override when given, otherwise the detected default for this repo's host.
|
||||
# A --login override always wins. Otherwise name this repo host's login only
|
||||
# as a best effort: the login name merely selects a per-login token, and
|
||||
# gitea_resolve_api_for_login falls back to the host credential
|
||||
# (get_gitea_token) when no tea login is named, so the default credential
|
||||
# still resolves even when the host tea has no matching login entry.
|
||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
|
||||
|
||||
# Bind the REST endpoint + token to the effective login, then derive the
|
||||
# acting identity from that SAME credential (GET /user). The write below and
|
||||
# its read-back both use this credential, so the write is verified against
|
||||
# the identity that actually performed it. Passing "explicit" when --login
|
||||
# was supplied forbids the host-default fallback: an unresolvable explicit
|
||||
# override fails closed instead of writing under the default identity.
|
||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||
|
||||
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
||||
echo "Error: could not create and verify a comment on Gitea issue #$ISSUE_NUMBER via a provider-returned created id (#865)." >&2
|
||||
exit 1
|
||||
}
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
|
||||
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
||||
echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
|
||||
@@ -12,6 +12,7 @@ TITLE=""
|
||||
BODY=""
|
||||
LABELS=""
|
||||
MILESTONE=""
|
||||
INTERACTIVE=false
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
@@ -66,13 +67,15 @@ Options:
|
||||
-b, --body BODY Issue body/description
|
||||
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
||||
-m, --milestone NAME Milestone name to assign
|
||||
-i, --interactive Prompt for missing issue fields
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
||||
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
||||
$(basename "$0") -i
|
||||
EOF
|
||||
exit 1
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -94,8 +97,12 @@ while [[ $# -gt 0 ]]; do
|
||||
MILESTONE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-i|--interactive)
|
||||
INTERACTIVE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
@@ -104,6 +111,13 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$INTERACTIVE" == true ]]; then
|
||||
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
|
||||
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
|
||||
[[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true
|
||||
[[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true
|
||||
fi
|
||||
|
||||
if [[ -z "$TITLE" ]]; then
|
||||
echo "Error: Title is required (-t)" >&2
|
||||
usage
|
||||
@@ -127,6 +141,11 @@ case "$PLATFORM" in
|
||||
gitea_issue_create_api
|
||||
exit $?
|
||||
}
|
||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||
gitea_issue_create_api
|
||||
exit $?
|
||||
fi
|
||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
|
||||
@@ -36,7 +36,7 @@ Examples:
|
||||
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
||||
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
|
||||
EOF
|
||||
exit 1
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -67,7 +67,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
|
||||
@@ -37,7 +37,7 @@ Examples:
|
||||
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
|
||||
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
|
||||
EOF
|
||||
exit 1
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env bash
|
||||
# mutate-push-guard.sh -- regenerate the README's mutation table from MEASUREMENT.
|
||||
#
|
||||
# WHY THIS EXISTS. The README carried a hand-written mutation table quoting
|
||||
# "32/32" style results. Those numbers were true when typed and went stale in
|
||||
# silence as the suite grew. A README is what a reader trusts when the tool
|
||||
# misbehaves, so a confidently-wrong one is worse than none. Every number the
|
||||
# README prints about mutation now comes out of this script.
|
||||
#
|
||||
# ============================ WHAT THIS TOOL GOT WRONG ========================
|
||||
# Three defects, all found by review, all of the same shape: A TOOL WHOSE ENTIRE
|
||||
# OUTPUT IS A COVERAGE CLAIM MUST BE HARDER TO FOOL THAN THE CODE IT MEASURES.
|
||||
#
|
||||
# 1. IT REPORTED FULL COVERAGE ON A RED BASELINE. A mutant was "killed" whenever
|
||||
# the suite reported any failure at all, and the baseline was run only at the
|
||||
# END and never required to be green. So ONE pre-existing suite failure --
|
||||
# changing no guard behaviour whatsoever -- satisfied EVERY mutant: 13 killed,
|
||||
# 0 survived, a confident table generated and pasted into the README, exit 0.
|
||||
# Now: the baseline runs FIRST and must be exit-0 with zero failures, and a
|
||||
# kill requires the mutant to break a case THE BASELINE PASSED, recorded BY
|
||||
# NAME. A tally is not evidence; a named delta is.
|
||||
#
|
||||
# 2. IT MUTATED THE REVIEWED SOURCE IN PLACE. Restoration leaned on an EXIT trap.
|
||||
# A TRAP IS CLEANUP, NOT ISOLATION -- SIGKILL cannot run it. An interrupted run
|
||||
# left push-guard.sh mutated in the working tree, and the reviewer's NEXT
|
||||
# suite run silently inherited it. A verification tool that alters its subject
|
||||
# can leave the subject wrong in a way the next measurement believes.
|
||||
# Now: the subject is copied into a temp dir and only the COPY is ever
|
||||
# written to. The source is untouched BY CONSTRUCTION rather than by cleanup,
|
||||
# which is the only version of this that survives kill -9.
|
||||
#
|
||||
# 3. ITS WORK DIR WAS SHARED. Concurrent runs interfered through the suite's
|
||||
# default .work directory. Each run now gets its own.
|
||||
#
|
||||
# (Note the deliberate asymmetry with verify-clean-clone.sh, which forbids cp:
|
||||
# there the copy LAUNDERED the property under measurement, so measuring a copy
|
||||
# was the defect. Here mutation is destructive by design, so copying is what
|
||||
# PROTECTS the subject. The rule is not "never copy" -- it is "know whether the
|
||||
# copy preserves the property you are about to measure.")
|
||||
#
|
||||
# ================== THREE WAYS A MUTATION RUN LIES, AND THE GUARD FOR EACH ====
|
||||
# A. THE ANCHOR NO LONGER MATCHES. The mutant is never applied, the suite is
|
||||
# green, and the report says SURVIVED -- the same word a real coverage gap
|
||||
# gets. Guarded: ANCHOR MISSING is a loud failure.
|
||||
# B. THE ANCHOR MATCHES PROSE. This one landed on the first run: a mutant aimed
|
||||
# at a branch matched inside the usage() heredoc, edited a help string,
|
||||
# changed no behaviour, and duly reported SURVIVED. A documentation edit was
|
||||
# one step from being recorded as an uncovered branch. A MUTATION THAT CANNOT
|
||||
# CHANGE BEHAVIOUR IS NOT A SURVIVING MUTANT, IT IS A NON-MEASUREMENT.
|
||||
# Guarded: anchors resolving inside usage() are refused.
|
||||
# C. THE ANCHOR IS AMBIGUOUS. Two unrelated branches here are both the line
|
||||
# `if (( status != 0 )); then`; a first-match replace would credit the kill
|
||||
# to the wrong branch. Guarded: a match count != 1 refuses rather than guesses.
|
||||
set -uo pipefail
|
||||
|
||||
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
# --dir exists so this tool can be pointed at a FIXTURE copy and tested.
|
||||
--dir) SRC_DIR="$(cd "$2" && pwd)"; shift 2 ;;
|
||||
*) printf 'usage error: unknown argument: %s\n' "$1" >&2; exit 64 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SRC_TARGET="$SRC_DIR/push-guard.sh"
|
||||
SRC_SUITE="$SRC_DIR/test-push-guard.sh"
|
||||
for f in "$SRC_TARGET" "$SRC_SUITE"; do
|
||||
[[ -r "$f" ]] || { printf 'REFUSING: cannot read %s\n' "$f" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# --- ISOLATION, NOT CLEANUP --------------------------------------------------
|
||||
# Everything below writes only inside WORK. The trap is a courtesy for disk
|
||||
# space; correctness does not depend on it running.
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
install -m 755 "$SRC_TARGET" "$WORK/push-guard.sh"
|
||||
install -m 755 "$SRC_SUITE" "$WORK/test-push-guard.sh"
|
||||
TARGET="$WORK/push-guard.sh"
|
||||
SUITE="$WORK/test-push-guard.sh"
|
||||
BAK="$WORK/push-guard.sh.orig"
|
||||
cp "$TARGET" "$BAK"
|
||||
# Per-run work dir: the suite otherwise defaults to a shared .work beside itself,
|
||||
# and two concurrent runs corrupt each other's fixtures.
|
||||
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
||||
|
||||
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
||||
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)"
|
||||
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
||||
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
||||
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# passing_cases <output> -- names of cases that PASSED, one per line
|
||||
passing_cases() { printf '%s\n' "$1" | sed -n 's/^ PASS \[[^]]*\] \(.*\) (exit [0-9]*)$/\1/p'; }
|
||||
tally() { printf '%s\n' "$1" | grep -E 'needles: [0-9]+ passed' | tail -1; }
|
||||
|
||||
# --- THE BASELINE MUST BE GREEN, AND IT IS ESTABLISHED FIRST ------------------
|
||||
printf '=== baseline (must be exit 0 with zero failures) ===\n'
|
||||
BASE_OUT="$("$SUITE" 2>&1)"; BASE_RC=$?
|
||||
BASE_LINE="$(tally "$BASE_OUT")"
|
||||
BASE_FAILED="$(printf '%s\n' "$BASE_LINE" | sed -n 's/.*, \([0-9]*\) failed.*/\1/p')"
|
||||
if (( BASE_RC != 0 )) || [[ -z "$BASE_LINE" || "$BASE_FAILED" != "0" ]]; then
|
||||
printf 'REFUSING: baseline is not green -- exit %s, tally: %s\n' \
|
||||
"$BASE_RC" "${BASE_LINE:-<no tally emitted>}" >&2
|
||||
printf '\nEvery mutant would be scored KILLED by the pre-existing failure, and this\n' >&2
|
||||
printf 'tool would publish a confident coverage table that measured nothing. Fix the\n' >&2
|
||||
printf 'suite first. NO TABLE IS EMITTED.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf ' %s\n' "$BASE_LINE"
|
||||
mapfile -t BASE_PASSING < <(passing_cases "$BASE_OUT")
|
||||
printf ' %d named cases passing at baseline\n' "${#BASE_PASSING[@]}"
|
||||
if (( ${#BASE_PASSING[@]} == 0 )); then
|
||||
printf 'REFUSING: could not parse any case names -- kills could not be attributed.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf ' prose (usage heredoc) is lines %s-%s -- anchors there are refused, not scored\n\n' \
|
||||
"$PROSE_LO" "$PROSE_HI"
|
||||
|
||||
rc_all=0
|
||||
KILLED=0; SURVIVED=0
|
||||
declare -a ROWS=()
|
||||
|
||||
mutate() {
|
||||
local name="$1" find="$2" repl="$3"
|
||||
# Count and locate in python so MULTI-LINE anchors work. They are required:
|
||||
# two unrelated branches in this file are both the single line
|
||||
# `if (( status != 0 )); then`, and a one-line anchor cannot say which one a
|
||||
# result belongs to. Guessing would attribute a kill to the wrong branch.
|
||||
local loc; loc="$(python3 - "$BAK" "$find" <<'LOCPY'
|
||||
import sys
|
||||
s = open(sys.argv[1]).read(); find = sys.argv[2]
|
||||
n = s.count(find)
|
||||
print(n, (s[:s.index(find)].count("\n") + 1) if n else 0)
|
||||
LOCPY
|
||||
)"
|
||||
local n="${loc%% *}" ln="${loc##* }"
|
||||
if (( n == 0 )); then
|
||||
printf ' !! ANCHOR MISSING %-46s NOT APPLIED -- result would be meaningless\n' "$name"
|
||||
rc_all=1; return
|
||||
fi
|
||||
if (( n != 1 )); then
|
||||
printf ' !! ANCHOR AMBIGUOUS %-46s %d matches -- refusing to guess which branch\n' "$name" "$n"
|
||||
rc_all=1; return
|
||||
fi
|
||||
if (( ln >= PROSE_LO && ln <= PROSE_HI )); then
|
||||
printf ' !! ANCHOR IS PROSE %-46s line %d is inside usage() -- not a branch\n' "$name" "$ln"
|
||||
rc_all=1; return
|
||||
fi
|
||||
|
||||
python3 - "$BAK" "$TARGET" "$find" "$repl" <<'MUTPY'
|
||||
import sys
|
||||
src, dst, find, repl = sys.argv[1:5]
|
||||
open(dst, "w").write(open(src).read().replace(find, repl, 1))
|
||||
MUTPY
|
||||
local out; out="$("$SUITE" 2>&1)"
|
||||
cp "$BAK" "$TARGET"
|
||||
|
||||
local line; line="$(tally "$out")"
|
||||
if [[ -z "$line" ]]; then
|
||||
printf ' !! NO TALLY %-46s suite produced no needle count\n' "$name"
|
||||
rc_all=1; return
|
||||
fi
|
||||
# A KILL IS A NAMED DELTA, NOT A TALLY. Cases that passed at baseline and no
|
||||
# longer pass are the evidence; anything else (a case that was already
|
||||
# failing, a suite that died early) cannot be credited to this mutant.
|
||||
local now; now="$(passing_cases "$out")"
|
||||
local -a broke=()
|
||||
local c
|
||||
for c in "${BASE_PASSING[@]}"; do
|
||||
grep -qxF -- "$c" <<<"$now" || broke+=("$c")
|
||||
done
|
||||
local total="${#BASE_PASSING[@]}"
|
||||
|
||||
if (( ${#broke[@]} > 0 )); then
|
||||
printf ' KILLED L%-5s %-46s %d/%d fail\n' "$ln" "$name" "${#broke[@]}" "$total"
|
||||
printf ' by: %s\n' "${broke[0]}"
|
||||
(( ${#broke[@]} > 1 )) && printf ' +%d more\n' "$(( ${#broke[@]} - 1 ))"
|
||||
ROWS+=("| \`$name\` (L$ln) | ${#broke[@]}/$total fail | killed |")
|
||||
KILLED=$(( KILLED + 1 ))
|
||||
else
|
||||
printf ' SURVIVED L%-5s %-46s 0/%d fail <-- UNCOVERED BRANCH\n' "$ln" "$name" "$total"
|
||||
ROWS+=("| \`$name\` (L$ln) | 0/$total fail | **SURVIVED** |")
|
||||
SURVIVED=$(( SURVIVED + 1 )); rc_all=1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== push-guard mutation run ==="
|
||||
# EVERY ANCHOR BELOW IS VERBATIM SOURCE TEXT OF THE GUARD, so the single quotes
|
||||
# are load-bearing: these strings must reach `mutate` as the CHARACTERS that
|
||||
# appear in push-guard.sh. Expanding them would search for THIS shell's (unset)
|
||||
# $rel, $cmode, $EX_CONFIG and match nothing -- which the anchor guards would
|
||||
# report as ANCHOR MISSING rather than silently, but the intent is still to
|
||||
# forbid expansion. The directive is scoped to this function so it cannot mask a
|
||||
# genuine unintended-literal anywhere else in the file.
|
||||
# shellcheck disable=SC2016
|
||||
run_mutants() {
|
||||
mutate "json decision requirement bypassed" \
|
||||
' if (( ${#JSON_PATHS[@]} == 0 )); then' ' if false; then'
|
||||
mutate "opt-out accepted with no written reason" \
|
||||
'if not isinstance(reason, str) or not reason.strip():' 'if False:'
|
||||
mutate "committed re-read of the opt-out skipped" \
|
||||
' [[ "$CFG_MODE" == "none" ]] || return 0' ' return 0'
|
||||
mutate "untracked config honoured as an opt-out" \
|
||||
' if [[ -z "$rel" ]]; then' ' if false; then'
|
||||
mutate "staged-but-uncommitted opt-out honoured" \
|
||||
' if [[ -z "$cmode" ]]; then' ' if false; then'
|
||||
mutate "committed SYMLINK config honoured" \
|
||||
' if [[ "$cmode" == "120000" ]]; then' ' if false; then'
|
||||
mutate "unparseable committed config ignored" \
|
||||
' if (( cstatus != 0 )); then' ' if false; then'
|
||||
mutate "local-only opt-out (HEAD says ON) honoured" \
|
||||
' if [[ "$cmode_val" != "none" ]]; then' ' if false; then'
|
||||
mutate "empty MERGE exempted" \
|
||||
' if [[ "$all_same" == yes ]]; then' ' if false; then'
|
||||
mutate "empty ROOT exempted" \
|
||||
'if [[ -z "$(git diff-tree --root -r --name-only --no-commit-id HEAD)" ]]; then' \
|
||||
'if false; then'
|
||||
mutate "--since-head ancestry check removed" \
|
||||
'if ! git merge-base --is-ancestor "$since_head" "$head"; then' 'if false; then'
|
||||
# guard's own source text, matched verbatim. Expanding them here would search for
|
||||
# this shell's (empty) $EX_CONFIG instead of the characters in the file.
|
||||
mutate "staged-file enumeration ignores git failure" \
|
||||
"$(printf 'if (( status != 0 )); then\n local msg')" \
|
||||
"$(printf 'if false; then\n local msg')"
|
||||
mutate "malformed config degrades to absent instead of refusing" \
|
||||
"$(printf 'if (( status != 0 )); then\n fail "$EX_CONFIG"')" \
|
||||
"$(printf 'if false; then\n fail "$EX_CONFIG"')"
|
||||
}
|
||||
|
||||
run_mutants
|
||||
|
||||
printf '\nbaseline: %s\n' "$BASE_LINE"
|
||||
printf '%d killed, %d survived\n' "$KILLED" "$SURVIVED"
|
||||
|
||||
printf '\n--- README TABLE (paste verbatim) ---\n'
|
||||
printf '| mutation | suite result | verdict |\n|---|---|---|\n'
|
||||
printf '| *unmodified* | %s | baseline |\n' "$(printf '%s' "$BASE_LINE" | sed 's/push-guard needles: //')"
|
||||
printf '%s\n' "${ROWS[@]}"
|
||||
exit "$rc_all"
|
||||
@@ -86,7 +86,7 @@ Examples:
|
||||
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
||||
$(basename "$0") -t "WIP: New feature" --draft
|
||||
EOF
|
||||
exit 1
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -125,7 +125,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
@@ -183,6 +183,11 @@ case "$PLATFORM" in
|
||||
gitea_pr_create_api
|
||||
exit $?
|
||||
}
|
||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||
gitea_pr_create_api
|
||||
exit $?
|
||||
fi
|
||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
|
||||
@@ -34,7 +34,7 @@ Examples:
|
||||
$(basename "$0") -s merged -a username # Merged PRs by user
|
||||
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
||||
EOF
|
||||
exit 1
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -61,7 +61,7 @@ while [[ $# -gt 0 ]]; do
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
|
||||
@@ -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] [--skip-queue-guard]
|
||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||
|
||||
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,17 +25,16 @@ 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
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
@@ -53,17 +52,16 @@ while [[ $# -gt 0 ]]; do
|
||||
DELETE_BRANCH=true
|
||||
shift
|
||||
;;
|
||||
--skip-queue-guard)
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
SKIP_QUEUE_GUARD=true
|
||||
shift
|
||||
;;
|
||||
--expect-head)
|
||||
EXPECT_HEAD="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
usage 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
@@ -86,18 +84,36 @@ 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 [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
||||
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
|
||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||
--purpose merge \
|
||||
-B "$BASE_BRANCH" \
|
||||
-B "$HEAD_BRANCH" \
|
||||
-R "$HEAD_REPO" \
|
||||
--sha "$HEAD_SHA" \
|
||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||
fi
|
||||
@@ -106,31 +122,22 @@ 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='{"Do":"squash"}'
|
||||
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
|
||||
)
|
||||
|
||||
token=$(get_gitea_token "$host" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
@@ -202,7 +209,7 @@ fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
||||
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||
"${cmd[@]}"
|
||||
;;
|
||||
@@ -211,32 +218,9 @@ case "$PLATFORM" in
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
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
|
||||
# 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"
|
||||
;;
|
||||
*)
|
||||
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,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||
write_metadata "$METADATA"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
OWNER=$(get_repo_owner)
|
||||
@@ -182,6 +182,25 @@ 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'),
|
||||
@@ -207,6 +226,8 @@ 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,16 +1,47 @@
|
||||
#!/bin/bash
|
||||
# pr-review.sh - Review a pull request on GitHub or Gitea
|
||||
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]
|
||||
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] [-r owner/repo] [-H host]
|
||||
#
|
||||
# Gitea reviews and comments are written through the supported REST API, not
|
||||
# `tea`: tea 0.11.1 cannot emit the id of a record it creates and can silently
|
||||
# no-op while exiting 0 (#865 defect class), so an exit code is the only — and
|
||||
# untrustworthy — signal it offers. approve/request-changes POST to
|
||||
# /pulls/{n}/reviews (returns the created review with its id); the `comment`
|
||||
# action POSTs to /issues/{n}/comments (returns the created comment with its
|
||||
# id). Each write is then verified by GETting that exact returned id, so a
|
||||
# concurrent record cannot masquerade as this write and a no-op fails closed.
|
||||
#
|
||||
# --login override: the default login is resolved from the local tea login list
|
||||
# for this repo's host (get_gitea_login_for_host). Pass --login <name> to
|
||||
# override it for this invocation only. The REST write, the /user identity read,
|
||||
# and every read-back are ALL performed with the token of the EFFECTIVE login,
|
||||
# so the write and its verification bind to the same identity.
|
||||
#
|
||||
# -r/--repo override: explicit owner/repo slug, skipping git-remote slug
|
||||
# inference — mirrors the -r convention of the sibling wrappers (pr-view.sh,
|
||||
# pr-diff.sh, pr-ci-wait.sh; mosaicstack/stack #867) for reviewer worktrees
|
||||
# whose origin is nonstandard or missing. -H/--host makes the target Gitea
|
||||
# instance explicit too (skips remote-host inference), so ambient CWD/remote
|
||||
# state can no longer cross-wire the review to the wrong instance. With -r, the
|
||||
# resolved repo is preflighted (GET .../repos/<slug>) BEFORE any write so a
|
||||
# wrong-host cross-wire surfaces as a clear preflight error instead of an opaque
|
||||
# write-404. Every Gitea curl (write, read-back, preflight) carries a
|
||||
# `User-Agent: mosaic-pr-review` header, since some Cloudflare-fronted Gitea
|
||||
# hosts intermittently reject curl's default User-Agent.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Parse arguments
|
||||
PR_NUMBER=""
|
||||
ACTION=""
|
||||
COMMENT=""
|
||||
LOGIN_OVERRIDE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
@@ -26,13 +57,28 @@ while [[ $# -gt 0 ]]; do
|
||||
COMMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
-l|--login)
|
||||
LOGIN_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r|--repo)
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-H|--host)
|
||||
HOST_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]"
|
||||
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] [-r owner/repo] [-H host]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number PR number (required)"
|
||||
echo " -a, --action Review action: approve, request-changes, comment (required)"
|
||||
echo " -c, --comment Review comment (required for request-changes)"
|
||||
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
|
||||
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
|
||||
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
|
||||
echo " -h, --help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
@@ -53,7 +99,554 @@ if [[ -z "$ACTION" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
# An explicit --repo is the whole point of a reviewer worktree whose origin
|
||||
# is nonstandard or missing (#867 convention, mirrored from pr-view.sh /
|
||||
# pr-diff.sh): do not hard-fail platform detection on a missing/foreign
|
||||
# origin — assume gitea, the only platform --repo/--host target.
|
||||
detect_platform >/dev/null 2>&1 || PLATFORM="gitea"
|
||||
else
|
||||
detect_platform >/dev/null
|
||||
fi
|
||||
|
||||
# Render the provider's own explanation for a failed request, for appending to
|
||||
# an error message (#1004). Every HTTP arm in this file already has the response
|
||||
# body on disk; without this it was discarded unread at exactly the moment the
|
||||
# caller needed it, which pushes an operator toward re-issuing the request by
|
||||
# hand to find out what the server said. Gitea returns {"message": "..."} on a
|
||||
# refusal; anything unparseable falls back to a truncated raw first line so a
|
||||
# proxy's HTML error page still says something. Prints "" when there is nothing
|
||||
# to add, so callers can interpolate unconditionally.
|
||||
#
|
||||
# Args: $1 = path to the response body file.
|
||||
gitea_error_detail() {
|
||||
local body_file="$1"
|
||||
[[ -s "$body_file" ]] || return 0
|
||||
python3 - "$body_file" <<'PY' 2>/dev/null || true
|
||||
import json
|
||||
import sys
|
||||
|
||||
LIMIT = 300
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as response:
|
||||
raw = response.read().strip()
|
||||
except OSError:
|
||||
raise SystemExit(0)
|
||||
if not raw:
|
||||
raise SystemExit(0)
|
||||
detail = ""
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict):
|
||||
for key in ("message", "error", "errors"):
|
||||
value = parsed.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
detail = value.strip()
|
||||
break
|
||||
if isinstance(value, list) and value:
|
||||
detail = "; ".join(str(item) for item in value).strip()
|
||||
break
|
||||
except ValueError:
|
||||
pass
|
||||
if not detail:
|
||||
detail = raw.splitlines()[0].strip()
|
||||
if not detail:
|
||||
raise SystemExit(0)
|
||||
if len(detail) > LIMIT:
|
||||
detail = detail[:LIMIT] + "..."
|
||||
print(f" — provider said: {detail}")
|
||||
PY
|
||||
}
|
||||
|
||||
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
|
||||
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
|
||||
# write is a direct POST that returns the created comment object, so we learn
|
||||
# the exact id of THIS write; we GET that exact id and require id == created id
|
||||
# AND author == acting identity AND exact body AND that it belongs to this PR.
|
||||
# Keying to the returned id means no concurrent comment (even same identity /
|
||||
# body) can masquerade as this write, and a no-op create yields no id and fails
|
||||
# closed. Requires GITEA_API_BASE / GITEA_API_TOKEN to be resolved first (via
|
||||
# gitea_resolve_api_for_login). Prints the created comment id on success.
|
||||
#
|
||||
# Args: $1 = PR number, $2 = comment body, $3 = acting identity login.
|
||||
gitea_create_comment_verified() {
|
||||
local pr_number="$1" comment_body="$2" acting_login="$3"
|
||||
local payload write_file readback_file auth_config write_status readback_status created_id
|
||||
|
||||
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
||||
')
|
||||
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
|
||||
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$write_file" "$readback_file"
|
||||
echo "Error: could not stage Gitea credential for comment write" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
|
||||
|
||||
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
||||
-X POST \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$GITEA_API_BASE/issues/$pr_number/comments"); then
|
||||
echo "Error: Gitea comment write transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$write_status" != "201" ]]; then
|
||||
echo "Error: Gitea comment write failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
created_id=$(python3 - "$write_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comment = json.load(response)
|
||||
created_id = comment.get("id") if isinstance(comment, dict) else None
|
||||
if not isinstance(created_id, int) or created_id <= 0:
|
||||
raise ValueError("create response carried no positive comment id")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(created_id)
|
||||
PY
|
||||
) || return 1
|
||||
|
||||
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
"$GITEA_API_BASE/issues/comments/$created_id"); then
|
||||
echo "Error: Gitea comment read-back transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$readback_status" != "200" ]]; then
|
||||
echo "Error: Gitea comment read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
|
||||
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
|
||||
EXPECTED_NUMBER="$pr_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
|
||||
python3 - "$readback_file" <<'PY' || return 1
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comment = json.load(response)
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("response is not a comment object")
|
||||
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
||||
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
slug = os.environ["EXPECTED_REPO_SLUG"]
|
||||
number = os.environ["EXPECTED_NUMBER"]
|
||||
web_base = os.environ["EXPECTED_WEB_BASE"]
|
||||
# Gitea populates WEB (html) URLs here, not API paths. A PR-conversation
|
||||
# comment carries pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (with
|
||||
# issue_url empty), while a plain issue comment carries
|
||||
# issue_url = <web_base>/<owner>/<repo>/issues/<n> (with pull_request_url empty).
|
||||
# This is the pr-review `comment` action, so the comment MUST land on a pull
|
||||
# request: require pull_request_url. A plain issue_url is REJECTED — if issue
|
||||
# #N exists but PR #N does not, POST /issues/N/comments creates an issue
|
||||
# comment, and accepting that issue_url would let the wrapper falsely report a
|
||||
# verified PR comment (issue-comment.sh legitimately keeps the broader
|
||||
# issue-or-PR acceptance; a PR review does not).
|
||||
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
|
||||
# provider + repo + kind + number — an endswith/suffix test would accept a
|
||||
# look-alike host (evil.example/deceptive/<slug>/pulls/N) or a same-host
|
||||
# decoy prefix (/other/<slug>/pulls/N), so compare the whole thing.
|
||||
base_origin, base_path = _origin_and_path(web_base)
|
||||
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
|
||||
|
||||
def _belongs(url, expected_path):
|
||||
if not url:
|
||||
return False
|
||||
origin, path = _origin_and_path(url)
|
||||
# Repo owner/repo slugs are case-insensitive (Gitea canonicalizes the
|
||||
# pull_request_url slug to lowercase on return), while EXPECTED_REPO_SLUG
|
||||
# is taken verbatim from GITEA_API_BASE and may be mixed-case. The
|
||||
# remainder of the path (".../pulls/<number>") is numeric, so lowercasing
|
||||
# the whole path for this comparison only relaxes case, not identity: the
|
||||
# origin tuple (scheme+host+port) above still pins the provider host, and
|
||||
# the path is still compared in FULL (no endswith/suffix match), so the
|
||||
# look-alike-host and same-host decoy-prefix protections are unchanged.
|
||||
return origin == base_origin and path.lower() == expected_path.lower()
|
||||
|
||||
if comment.get("id") != expected_id:
|
||||
raise ValueError("read-back id does not match the created id")
|
||||
if (comment.get("user") or {}).get("login") != acting_login:
|
||||
raise ValueError("created comment is not authored by the acting identity")
|
||||
if comment.get("body") != expected_body:
|
||||
raise ValueError("created comment body does not match")
|
||||
if not _belongs(comment.get("pull_request_url"), expected_pr_path):
|
||||
raise ValueError("claimed PR comment did not land on a pull request (kind=pulls) on this provider/repo")
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
echo "$created_id"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Resolve and cache the Gitea REST endpoint + token for the current remote,
|
||||
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
|
||||
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
|
||||
#
|
||||
# The token is resolved for the EFFECTIVE login (the --login override when
|
||||
# given, otherwise the detected default), so the one credential used to submit
|
||||
# the review/comment ALSO drives the /user identity read and every read-back —
|
||||
# write token and read-back token are the same identity by construction. This
|
||||
# is the credential-ordering fix: a --login override is no longer submitted
|
||||
# under one credential and verified under a different default one. Falls back to
|
||||
# the host-scoped credential ONLY when NO --login override was supplied (the
|
||||
# best-effort default path). When $2 is "explicit" the login came from a
|
||||
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
|
||||
# CLOSED rather than silently downgrading the review/comment to the host default
|
||||
# identity. Returns non-zero (clear stderr) on any resolution failure.
|
||||
#
|
||||
# Honors the module-level REPO_OVERRIDE / HOST_OVERRIDE (-r/--repo, -H/--host):
|
||||
# when set, they skip git-remote slug/host inference entirely — for reviewer
|
||||
# worktrees whose origin is nonstandard or missing, and to make the target
|
||||
# instance fully deterministic (an ambient CWD/remote can otherwise cross-wire
|
||||
# a review to the wrong Gitea host). When -r/--repo is used, the resolved repo
|
||||
# is preflighted (GET .../repos/<slug>) BEFORE any write: a wrong-host
|
||||
# cross-wire would otherwise surface only as an opaque write-404 with zero
|
||||
# residue.
|
||||
gitea_resolve_api_for_login() {
|
||||
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
|
||||
local preflight_auth_config preflight_status
|
||||
|
||||
if [[ -n "$HOST_OVERRIDE" ]]; then
|
||||
host="$HOST_OVERRIDE"
|
||||
else
|
||||
host=$(get_remote_host)
|
||||
fi
|
||||
if [[ -n "$override_explicit" ]]; then
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
|
||||
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
|
||||
return 1
|
||||
}
|
||||
else
|
||||
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|
||||
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
|
||||
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||
echo "Error: Configured Gitea URL not found for review read-back verification" >&2
|
||||
return 1
|
||||
}
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
repo="$REPO_OVERRIDE"
|
||||
else
|
||||
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
||||
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
GITEA_API_ROOT="${configured_url%/}/api/v1"
|
||||
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
|
||||
# The provider WEB base (scheme + host + effective port + any deployment path
|
||||
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
|
||||
# Read-back verification pins the returned URL's origin + path prefix to THIS,
|
||||
# not just a repo/PR suffix.
|
||||
GITEA_WEB_BASE="${configured_url%/}"
|
||||
|
||||
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||
preflight_auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
echo "Error: could not stage Gitea credential for --repo preflight" >&2
|
||||
return 1
|
||||
}
|
||||
preflight_status=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
--config "$preflight_auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
"$GITEA_API_BASE") || preflight_status="000"
|
||||
rm -f "$preflight_auth_config"
|
||||
if [[ "$preflight_status" != "200" ]]; then
|
||||
echo "Error: repo '$repo' not reachable at $configured_url (HTTP $preflight_status) — wrong host? pass -H/--host <gitea-host> or cd into the target checkout" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Resolve the login of the identity the API token authenticates as (GET
|
||||
# /user). Used to attribute a read-back review to THIS action's reviewer so a
|
||||
# concurrent review from a DIFFERENT identity cannot satisfy verification.
|
||||
# Prints the login on success.
|
||||
gitea_authenticated_login() {
|
||||
local response_file auth_config status
|
||||
|
||||
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-whoami.XXXXXX")
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$response_file"
|
||||
echo "Error: could not stage Gitea credential for identity read" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$response_file" "$auth_config"' RETURN
|
||||
|
||||
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
"$GITEA_API_ROOT/user"); then
|
||||
echo "Error: Gitea authenticated-identity read transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$status" != "200" ]]; then
|
||||
echo "Error: Gitea authenticated-identity read failed with HTTP $status$(gitea_error_detail "$response_file")" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
python3 - "$response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
user = json.load(response)
|
||||
login = user.get("login") if isinstance(user, dict) else None
|
||||
if not isinstance(login, str) or not login:
|
||||
raise ValueError("missing authenticated login")
|
||||
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
|
||||
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(login)
|
||||
PY
|
||||
}
|
||||
|
||||
# GET /pulls/{n} into a caller-owned response file and print its head commit
|
||||
# SHA. This core sets NO RETURN trap and reuses a caller-provided auth config +
|
||||
# response file, so it is safe to call from INSIDE another trapped function
|
||||
# (the post-verify re-read below) without clobbering that function's cleanup
|
||||
# trap. $1 = PR number, $2 = response file, $3 = curl auth config file.
|
||||
gitea_read_pr_head_into() {
|
||||
local pr_number="$1" pr_file="$2" auth_config="$3" status
|
||||
|
||||
if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
"$GITEA_API_BASE/pulls/$pr_number"); then
|
||||
echo "Error: Gitea PR head read transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$status" != "200" ]]; then
|
||||
echo "Error: Gitea PR head read failed with HTTP $status$(gitea_error_detail "$pr_file")" >&2
|
||||
return 1
|
||||
fi
|
||||
python3 - "$pr_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
pr = json.load(response)
|
||||
head_sha = pr.get("head", {}).get("sha") if isinstance(pr, dict) else None
|
||||
if not isinstance(head_sha, str) or not head_sha:
|
||||
raise ValueError("missing PR head sha")
|
||||
except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError) as error:
|
||||
print(f"Error: could not resolve PR head commit: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(head_sha)
|
||||
PY
|
||||
}
|
||||
|
||||
# Resolve the PR's current head commit SHA (GET /pulls/{n}). The review is
|
||||
# submitted against — and later verified as pinned to — this exact commit, so a
|
||||
# stale review left over from an earlier push cannot be mistaken for this one.
|
||||
# Prints the head SHA on success.
|
||||
gitea_pr_head_sha() {
|
||||
local pr_number="$1" pr_file auth_config
|
||||
|
||||
pr_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$pr_file"
|
||||
echo "Error: could not stage Gitea credential for PR head read" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$pr_file" "$auth_config"' RETURN
|
||||
|
||||
gitea_read_pr_head_into "$pr_number" "$pr_file" "$auth_config"
|
||||
}
|
||||
|
||||
# Submit a review to a Gitea PR via the supported REST API and verify it against
|
||||
# a PROVIDER-RETURNED created id. tea 0.11.1's `pr approve`/`reject` cannot emit
|
||||
# the id of the review it created and can silently no-op while exiting 0 (#865
|
||||
# defect class), so this does NOT shell out to tea: it POSTs to
|
||||
# /pulls/{n}/reviews with the event (APPROVED / REQUEST_CHANGES), the PR head
|
||||
# commit_id, and the review body, which returns the created review object
|
||||
# including its id. It then GETs that exact review id and requires
|
||||
# id == created id AND author == acting identity AND state == expected AND
|
||||
# commit_id == PR head. Keying to the returned id means no concurrent review
|
||||
# (even same identity/state/head) can masquerade as this one, and a no-op
|
||||
# submit yields no id and fails closed. Prints the created review id on success.
|
||||
#
|
||||
# Args: $1 = PR number, $2 = event (APPROVED|REQUEST_CHANGES),
|
||||
# $3 = review body (may be empty for APPROVED), $4 = acting login,
|
||||
# $5 = PR head sha.
|
||||
gitea_submit_review_verified() {
|
||||
local pr_number="$1" event="$2" review_body="$3" acting_login="$4" head_sha="$5"
|
||||
local payload write_file readback_file recheck_file auth_config
|
||||
local write_status readback_status created_id live_head
|
||||
|
||||
payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c '
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"event": os.environ["REVIEW_EVENT"],
|
||||
"body": os.environ["REVIEW_BODY"],
|
||||
"commit_id": os.environ["REVIEW_COMMIT"],
|
||||
}))
|
||||
')
|
||||
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-submit.XXXXXX")
|
||||
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
|
||||
recheck_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-recheck.XXXXXX")
|
||||
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
|
||||
rm -f "$write_file" "$readback_file" "$recheck_file"
|
||||
echo "Error: could not stage Gitea credential for review submit" >&2
|
||||
return 1
|
||||
}
|
||||
trap 'rm -f "$write_file" "$readback_file" "$recheck_file" "$auth_config"' RETURN
|
||||
|
||||
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
|
||||
-X POST \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$GITEA_API_BASE/pulls/$pr_number/reviews"); then
|
||||
echo "Error: Gitea review submit transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
# Gitea returns 200 (occasionally 201) with the created review object.
|
||||
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
|
||||
echo "Error: Gitea review submit failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
created_id=$(python3 - "$write_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
review = json.load(response)
|
||||
created_id = review.get("id") if isinstance(review, dict) else None
|
||||
if not isinstance(created_id, int) or created_id <= 0:
|
||||
raise ValueError("submit response carried no positive review id")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Error: could not identify created Gitea review: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(created_id)
|
||||
PY
|
||||
) || return 1
|
||||
|
||||
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
|
||||
--config "$auth_config" \
|
||||
-H 'User-Agent: mosaic-pr-review' \
|
||||
"$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then
|
||||
echo "Error: Gitea review read-back transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$readback_status" != "200" ]]; then
|
||||
echo "Error: Gitea review read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
EXPECTED_REVIEW_ID="$created_id" EXPECTED_STATE="$event" ACTING_LOGIN="$acting_login" \
|
||||
EXPECTED_HEAD_SHA="$head_sha" EXPECTED_REVIEW_BODY="$review_body" \
|
||||
python3 - "$readback_file" <<'PY' || return 1
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
review = json.load(response)
|
||||
if not isinstance(review, dict):
|
||||
raise ValueError("response is not a review object")
|
||||
expected_id = int(os.environ["EXPECTED_REVIEW_ID"])
|
||||
expected_state = os.environ["EXPECTED_STATE"]
|
||||
acting_login = os.environ["ACTING_LOGIN"]
|
||||
expected_head = os.environ["EXPECTED_HEAD_SHA"]
|
||||
expected_body = os.environ["EXPECTED_REVIEW_BODY"]
|
||||
if review.get("id") != expected_id:
|
||||
raise ValueError("read-back id does not match the created id")
|
||||
if (review.get("user") or {}).get("login") != acting_login:
|
||||
raise ValueError("created review is not authored by the acting identity")
|
||||
if review.get("state") != expected_state:
|
||||
raise ValueError("created review is not in the expected state")
|
||||
if review.get("commit_id") != expected_head:
|
||||
raise ValueError("created review is not pinned to the PR head commit")
|
||||
# Bind to the exact submitted body. On Gitea v1.25.4 SubmitReview may
|
||||
# finalize/reuse a pending review id whose Content was authored elsewhere;
|
||||
# the exact GET exposes the persisted body, so a mismatch (a reused/foreign
|
||||
# review carrying different Content) fails closed even when id/author/state/
|
||||
# head all line up. Require presence + string TYPE + exact equality rather
|
||||
# than `(body or "")`: the old coalesce treated a missing/null persisted body
|
||||
# as equal to an empty submitted one, so a non-empty submitted body that
|
||||
# persisted as null (a suppressed/lost body) would have passed. When a
|
||||
# non-empty body was submitted the persisted value MUST be that exact string;
|
||||
# when an empty body was submitted the persisted value must be empty or
|
||||
# absent (a non-empty persisted body is likewise a divergence — vice-versa).
|
||||
persisted_body = review.get("body")
|
||||
if expected_body == "":
|
||||
if persisted_body not in (None, ""):
|
||||
raise ValueError("created review carries a body but none was submitted")
|
||||
elif not isinstance(persisted_body, str) or persisted_body != expected_body:
|
||||
raise ValueError("created review body does not match the submitted body")
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea review persistence verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
|
||||
# Current-head TOCTOU close-out: the review verified above is pinned to
|
||||
# head_sha, but that head was read BEFORE the submit. Between then and now
|
||||
# the PR branch may have advanced (a force-push or a new commit), which would
|
||||
# leave this verified review attached to a now-superseded commit while the
|
||||
# live tip carries unreviewed code — yet the wrapper would still report
|
||||
# success. Re-read the LIVE PR head and require it STILL equals the submitted
|
||||
# SHA; if it advanced, fail closed (nonzero, no created id emitted, no
|
||||
# success line). This reuses the submit-scoped auth config + recheck file so
|
||||
# it neither leaks the token to argv nor clobbers this function's cleanup.
|
||||
live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file" "$auth_config") || {
|
||||
echo "Error: could not re-read Gitea PR head after review verification" >&2
|
||||
return 1
|
||||
}
|
||||
if [[ "$live_head" != "$head_sha" ]]; then
|
||||
echo "Error: Gitea PR head advanced from $head_sha to $live_head between review submit and verification; refusing to report a review pinned to a superseded commit (#865 current-head TOCTOU)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$created_id"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
case $ACTION in
|
||||
@@ -85,24 +678,98 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
case $ACTION in
|
||||
approve)
|
||||
tea pr approve "$PR_NUMBER" $(get_gitea_repo_args) ${COMMENT:+--comment "$COMMENT"}
|
||||
echo "Approved Gitea PR #$PR_NUMBER"
|
||||
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
||||
# under `set -e`, with no origin and no -H, previously killed the script
|
||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
||||
# that support running with no usable origin at all).
|
||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||
# A --login override always wins. Otherwise name this host's login
|
||||
# only as a best effort: the login name merely selects a per-login
|
||||
# token, and gitea_resolve_api_for_login falls back to the host
|
||||
# credential (get_gitea_token) when no tea login is named — so a host
|
||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
||||
# the default credential to resolve. The single resolved token is
|
||||
# then used for the write, the /user identity, and the read-back.
|
||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
||||
# Bind the REST endpoint + token to the effective login, then derive
|
||||
# the acting identity from that SAME credential so the review submit
|
||||
# and its read-back verify against the identity that performed them.
|
||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
||||
# The review body (if any) travels with the review itself in the REST
|
||||
# submit — the created review record carries it — so there is no
|
||||
# separate detached comment to reconcile.
|
||||
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "APPROVED" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
||||
echo "Error: could not submit and verify an APPROVED review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
||||
exit 1
|
||||
}
|
||||
echo "Approved and verified Gitea PR #$PR_NUMBER (review ID $review_id)"
|
||||
;;
|
||||
request-changes)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
echo "Error: Comment required for request-changes"
|
||||
exit 1
|
||||
fi
|
||||
tea pr reject "$PR_NUMBER" $(get_gitea_repo_args) --comment "$COMMENT"
|
||||
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
||||
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
||||
# under `set -e`, with no origin and no -H, previously killed the script
|
||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
||||
# that support running with no usable origin at all).
|
||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||
# A --login override always wins. Otherwise name this host's login
|
||||
# only as a best effort: the login name merely selects a per-login
|
||||
# token, and gitea_resolve_api_for_login falls back to the host
|
||||
# credential (get_gitea_token) when no tea login is named — so a host
|
||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
||||
# the default credential to resolve. The single resolved token is
|
||||
# then used for the write, the /user identity, and the read-back.
|
||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
|
||||
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
|
||||
echo "Error: could not submit and verify a REQUEST_CHANGES review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
||||
exit 1
|
||||
}
|
||||
echo "Requested changes and verified on Gitea PR #$PR_NUMBER (review ID $review_id)"
|
||||
;;
|
||||
comment)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
echo "Error: Comment required"
|
||||
exit 1
|
||||
fi
|
||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||
echo "Added comment to Gitea PR #$PR_NUMBER"
|
||||
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
|
||||
# below re-derives the real host from HOST_OVERRIDE/remote independently and
|
||||
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
|
||||
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
|
||||
# under `set -e`, with no origin and no -H, previously killed the script
|
||||
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
|
||||
# that support running with no usable origin at all).
|
||||
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
|
||||
# A --login override always wins. Otherwise name this host's login
|
||||
# only as a best effort: the login name merely selects a per-login
|
||||
# token, and gitea_resolve_api_for_login falls back to the host
|
||||
# credential (get_gitea_token) when no tea login is named — so a host
|
||||
# tea's login list need not enumerate exotic (e.g. ported) hosts for
|
||||
# the default credential to resolve. The single resolved token is
|
||||
# then used for the write, the /user identity, and the read-back.
|
||||
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
|
||||
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
|
||||
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
|
||||
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
|
||||
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
|
||||
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
|
||||
exit 1
|
||||
}
|
||||
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown action: $ACTION"
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# push-guard
|
||||
|
||||
Mechanical closure of one defect: **a verification that passes when the thing it verifies never happened.**
|
||||
|
||||
Three incidents in one evening, three agents, no coordination, same shape:
|
||||
|
||||
| # | Incident | Why the check passed |
|
||||
| --- | ----------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| 1 | `PUSH VERIFIED` reported after nothing was pushed | Commit had aborted, so local HEAD trivially equalled the remote ref |
|
||||
| 2 | An **empty commit** carrying another commit's message was pushed | A push was chained after a _failed_ commit and ran on stale state |
|
||||
| 3 | Conflict markers + invalid JSON committed into 70 generated files | Nothing asserted the generated output still parsed |
|
||||
|
||||
Each is an assertion satisfied by the null case. `push-guard.sh` asserts the **positive** fact instead: a new object exists, the remote _moved_, the content _parses_.
|
||||
|
||||
## Checks
|
||||
|
||||
| Sub-command | Asserts | Exit on failure |
|
||||
| -------------- | -------------------------------------------- | --------------- |
|
||||
| `check-staged` | index has no unmerged paths | `2` |
|
||||
| | no conflict markers in staged content | `2` |
|
||||
| | the conflict scan actually _completed_ | `2` |
|
||||
| | staged JSON under the nominated paths parses | `3` |
|
||||
| | **something is actually staged** | `5` |
|
||||
| | **an explicit JSON decision exists** | `6` |
|
||||
| `push` | HEAD advanced past `--since-head` | `5` |
|
||||
| | HEAD is a non-empty commit | `5` |
|
||||
| | remote **moved**, and now equals local HEAD | `4` |
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
push-guard.sh check-staged --json-path 'data/**/*.json'
|
||||
|
||||
BEFORE=$(git rev-parse HEAD)
|
||||
git commit -m "..."
|
||||
push-guard.sh push --remote origin --branch main --since-head "$BEFORE"
|
||||
```
|
||||
|
||||
`--since-head` is what distinguishes "committed nothing" from "committed something", and capturing the remote ref _before_ pushing is what makes `remote == local` mean anything. Both were missing from the guard that produced incident 1.
|
||||
|
||||
## Design decisions that measurement forced
|
||||
|
||||
These are the interesting part; each one was wrong in the first draft.
|
||||
|
||||
**A bare `=======` is deliberately NOT treated as a conflict marker.** It collides with reStructuredText underlines and ASCII rules. Every genuine conflict git writes also contains `<<<<<<<` and `>>>>>>>`, so requiring those loses no real detection. Measured against a real repository: **zero** false positives across the entire tracked tree.
|
||||
|
||||
**The JSON check is opt-in by path, not on-by-default.** The first draft checked every staged `*.json`, on the reasoning that broader is strictly stronger. Measured against the same repository: **17 of 119** tracked `.json` files fail a strict parse, _all legitimately_ — every `tsconfig*.json` is JSONC (comments are legal there) and the CA templates are Go templates that merely carry a `.json` suffix. An on-by-default check fires on ~14% of the repo's JSON, and a guard that cries wolf gets routed around until the bypass is habitual — at which point the bypass covers the true positives too. Broader was **weaker**.
|
||||
|
||||
**`--json-path` values are normalized to `:(glob)` magic.** Git's default pathspec matching makes `data/**/*.json` require at least one intermediate directory: it matches `data/sub/b.json` and _silently skips_ `data/a.json`. The obvious spelling would have delivered partial coverage with no warning.
|
||||
|
||||
## Found by independent review, after the needles were already green
|
||||
|
||||
An adversarial review (author ≠ reviewer) found two genuine fail-opens that 17 self-written needles, five mutation runs and a repo-wide false-positive measurement had all missed. Both were reproduced before being fixed, and both now have needles.
|
||||
|
||||
**Renamed files were invisible to every content check.** Git detects renames by default (`diff.renames=true` since 2.9), so `git mv` plus a small edit is reported as a single `R` entry — which `--diff-filter=ACM` does not match. Reproduced at 97% similarity: the guard printed `staged content OK` and exited 0 with conflict markers in the staged blob. Fixed with `--no-renames`.
|
||||
|
||||
There is a trap inside this one worth recording. With _only_ the renamed file staged, the guard exits 5 — the nothing-staged check fires because the file list came back empty. That looks like a catch and is pure accident; co-stage one ordinary file and the fail-open is total. The needle deliberately co-stages a clean file so it cannot pass for the wrong reason.
|
||||
|
||||
**`-I` skipped files marked binary in `.gitattributes`,** while the summary still counted them as scanned — a clean pass _and_ a false count. Fixed with `-a`.
|
||||
|
||||
The review also correctly identified one **vacuous control**: the positive `--since-head` case asserted only exit 0, which the push produces anyway, so deleting the entire `--since-head` block left it passing. It now asserts on output.
|
||||
|
||||
Two reported findings did **not** reproduce and were not acted on: `:(glob)` does still match a bare directory pathspec, and my first rename repro failed only because the edit dropped similarity below the detection threshold — a badly built test, not an absent bug. Re-testing properly is what confirmed it.
|
||||
|
||||
## The JSON decision is mandatory (`.push-guard.json`)
|
||||
|
||||
The first release announced `JSON check NOT REQUESTED` and continued. That was _visible_ rather than silent, which is better — but it is still an **absence**, and an absence is not reviewable. Nobody reads a line that has printed correctly ten thousand times. A repo that needed the check and never wired it up stayed unprotected forever, and nothing ever failed.
|
||||
|
||||
The decision is now required, from one of exactly two places:
|
||||
|
||||
```jsonc
|
||||
// nominate generated JSON for parse-checking
|
||||
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
|
||||
|
||||
// or opt out — the reason is REQUIRED and is printed on every run
|
||||
{"json_check": "none", "reason": "no generated JSON in this repo"}
|
||||
```
|
||||
|
||||
`--json-path` on the command line also satisfies it. Saying nothing is refused (exit `6`).
|
||||
|
||||
**The asymmetry is deliberate.** Turning the check _on_ is safe from anywhere, so a CLI flag suffices. Turning it _off_ is confined to a committed file, because that is the only form a human can review: you can read a reason in a diff, and you cannot review the fact that nobody typed a flag. An opt-out living in an ad-hoc command line is the old fail-open with extra steps.
|
||||
|
||||
A **malformed** config is refused outright rather than treated as absent — that fallback would mean a typo silently disables the check the file was written to enable. A config that parses but _states nothing_ (`{}`) is refused too: valid JSON that says nothing is the original defect wearing a config file as a disguise.
|
||||
|
||||
**Migration is a hard cutover, on purpose.** The tempting path is "warn for one release, then enforce" — but that warning phase _is_ the degrade-to-a-warning this tool forbids, and it leaves the fail-open open for exactly as long as the warning is ignored, which is indefinitely. Consumers add a config or the guard refuses. It breaks loudly, once. Two pre-existing cases in this repo's own harness were the first to pay that cost, which is the correct place to feel it.
|
||||
|
||||
## Known limitations — stated, not hidden
|
||||
|
||||
- `check-staged` inspects the index. Content added to the working tree _after_ it runs is not covered — it belongs in a `pre-commit` hook, where the window is smallest.
|
||||
- `push` hardcodes `HEAD:refs/heads/$branch`, so it cannot verify a commit built via plumbing on a different base. A `--sha` option would close this; it is deliberately not added without a needle.
|
||||
|
||||
## Test harness
|
||||
|
||||
`test-push-guard.sh` — 46 cases, each check in **both polarities**:
|
||||
|
||||
- **NEEDLE** — a deliberately broken fixture that must trip the guard.
|
||||
- **CONTROL** — a clean fixture that must pass.
|
||||
|
||||
Controls are not decoration. A guard that failed unconditionally would satisfy every needle and look fully covered. Several controls additionally assert on the guard's _output_ (`--out`), because exit 0 cannot distinguish "checked the files and they were fine" from "matched no files and had nothing to check" — two controls in an earlier revision were passing vacuously for exactly that reason.
|
||||
|
||||
### Mutation results — the harness was itself tested by breaking the guard
|
||||
|
||||
**Everything in this section is regenerated by `./mutate-push-guard.sh`, not typed.** The previous version of this table was hand-maintained: it was true when written, went stale as the suite grew, and ended up asserting `unmodified | 32/32 pass` against a 46-case suite. A README is what a reader consults when the tool misbehaves, so a confidently-wrong one is worse than none. Re-run the script and paste; do not edit the numbers.
|
||||
|
||||
| mutation | suite result | verdict |
|
||||
| ---------------------------------------------------------------- | ------------------- | -------- |
|
||||
| _unmodified_ | 46 passed, 0 failed | baseline |
|
||||
| `json decision requirement bypassed` (L482) | 3/43 fail | killed |
|
||||
| `opt-out accepted with no written reason` (L334) | 1/43 fail | killed |
|
||||
| `committed re-read of the opt-out skipped` (L405) | 5/43 fail | killed |
|
||||
| `untracked config honoured as an opt-out` (L409) | 1/43 fail | killed |
|
||||
| `staged-but-uncommitted opt-out honoured` (L425) | 1/43 fail | killed |
|
||||
| `committed SYMLINK config honoured` (L433) | 1/43 fail | killed |
|
||||
| `unparseable committed config ignored` (L444) | 1/43 fail | killed |
|
||||
| `local-only opt-out (HEAD says ON) honoured` (L459) | 1/43 fail | killed |
|
||||
| `empty MERGE exempted` (L704) | 1/43 fail | killed |
|
||||
| `empty ROOT exempted` (L715) | 1/43 fail | killed |
|
||||
| `--since-head ancestry check removed` (L665) | 1/43 fail | killed |
|
||||
| `staged-file enumeration ignores git failure` (L173) | 1/43 fail | killed |
|
||||
| `malformed config degrades to absent instead of refusing` (L375) | 4/43 fail | killed |
|
||||
|
||||
13 mutants, 0 survived.
|
||||
|
||||
**Why the denominator is 43 while the suite reports 46.** The generator attributes kills only to cases it can parse by name, which is the `PASS [KIND] <name> (exit N)` form. Of the 46 passing assertions, 45 print `PASS` and 43 of those match that form. The three excluded lines are:
|
||||
|
||||
```
|
||||
PASS [CONTROL] guard runs without emitting any interpreter warning
|
||||
PASS [NEEDLE ] the warning detector fires on a known warning string
|
||||
ok [e9-fixture ] fixture is a merge (3 fields) with tree identical to both parents
|
||||
```
|
||||
|
||||
The two `z1` warning assertions assert on a whole _class_ of output rather than an exit code, so they carry no `(exit N)`; `e9-fixture` is a fixture precondition and prints in the `ok` form. All three still run and still gate the suite — they are excluded from _attribution_, not from _execution_.
|
||||
|
||||
An earlier revision of this paragraph named the excluded set as `w2-fixture`, `e9-fixture` and `g1-fixture`. **That was written from memory instead of from the output, and two of the three names were wrong:** `w2-fixture` belongs to `test-verify-clean-clone.sh` and `g1-fixture` to `test-mutate-push-guard.sh`, so neither runs in this suite at all and neither could contribute to its tally. A reader auditing the denominator would have gone looking for them in the wrong files. It is recorded rather than quietly corrected because a confidently-wrong provenance inside the section that exists to make a generated number auditable is the same defect as everything else on this page: **a claim that reads as measured and is not.** The set above was produced by running the suite and applying the generator's own parser to its output.
|
||||
|
||||
Earlier mutants, run at the suite size of the day and kept as history rather than as a live claim: fail-open (9/16), always-fail (16/16 — controls catch it), revert `:(glob)` normalization (3/16, caught **only** by the `--out` assertions), drop the remote-did-not-move assertion (1/16), restore blanket `|| true` on the scan (1/17), revert `--no-renames` (1/19), revert `-a` to `-I` (1/19), delete the `--since-head` block (2/19, incl. the control that _was_ vacuous), stray-warning emission (1/32).
|
||||
|
||||
A guard nobody has watched fail is not a guard. The same applies to the harness: the `:(glob)` mutant would have passed silently without the output assertions, so the assertions are load-bearing rather than ornamental.
|
||||
|
||||
### Two branches were uncovered, and writing this table is what found them
|
||||
|
||||
Building the generator turned up two mutants that survived a **46-case suite reporting 46/46 green**: `staged-but-uncommitted opt-out honoured` and `unparseable committed config ignored`. Neither branch had any case at all. Both are now needled, which is why they read _killed_ above.
|
||||
|
||||
Both survivors share a shape worth naming: the mutant still exits `6`, because control falls through to a _sibling_ refusal that rejects for a different reason. **An exit-code-only assertion would have been satisfied by the wrong branch.** The new cases anchor on the distinguishing clause instead.
|
||||
|
||||
The same audit found a live instance of that defect already in the suite. Three separate branches print the headline `OPT-OUT IS NOT REVIEWABLE` — untracked, staged-not-committed, and symlink — and the untracked needle was anchored on the shared headline. Delete the untracked branch and control reaches a sibling printing those same words, so **the needle would not fail; it would re-point.** A substring anchor does not fail when its subject is removed. It is now anchored on `is not tracked in git`.
|
||||
|
||||
### The generator refuses three ways a mutation run can lie
|
||||
|
||||
1. **The anchor no longer matches.** The mutant is never applied, the suite is green, and a naive report says _survived_ — the same word a real coverage gap gets. `ANCHOR MISSING` is a loud failure instead.
|
||||
2. **The anchor matches prose.** This one landed on the first run: a mutant aimed at the refuse-on-missing-config branch matched inside the `usage()` heredoc, edited a help string, changed no behaviour, and duly reported _survived_. A documentation edit was one step from being recorded as an uncovered branch. **A mutation that cannot change behaviour is not a surviving mutant, it is a non-measurement** — and a non-measurement reported as a result is the same defect as the vacuous test the harness exists to hunt. Anchors resolving inside `usage()` are now refused, and the heredoc's bounds are located at runtime rather than hardcoded.
|
||||
3. **The anchor is ambiguous.** Two unrelated branches here are both the single line `if (( status != 0 )); then`; a first-match replace would silently attribute the kill to whichever came first. Multi-line anchors are supported and a match count `!= 1` refuses rather than guesses.
|
||||
|
||||
**A blanket `|| true` on the scan was a fail-open in the guard's own error handling.** `git grep` exits `1` for "no match" but `>=2` for a real failure. `|| true` collapsed the two, so a malformed pathspec or unreadable index would have been reported as "ok, no conflict markers" — a clean pass from a scan that never ran. The status is now discriminated, and a PATH-shim fault-injection needle proves the refusal.
|
||||
|
||||
**A `<<'PY'` heredoc inside `$( )` made bash print a warning on every run — and 30 needles plus a clean linter all missed it.** The config parser started life as an inline heredoc inside a command substitution, so every invocation emitted `warning: command substitution: 1 unterminated here-document` to stderr. It executed correctly, every needle stayed green, and shellcheck reported nothing. The harness missed it because `expect` asserts _substrings it was told to look for_ — and nobody tells you to look for output you did not know existed. It surfaced only when the guard was run against a real repository.
|
||||
|
||||
The fix moves the parser to a top-level constant. The lesson is encoded as case `z1`, which asserts the **absence of a whole output class** rather than the presence of an expected string, and is paired with a needle proving the detector can fire. A tool built to refuse quiet failures was quietly polluting stderr for its entire existence.
|
||||
|
||||
**The `-E` flag on `git grep` is load-bearing and was caught by a needle, not by review.** `git grep` defaults to _basic_ regex, in which `(`, `|` and `{7}` are literal characters. Without `-E` the patterns match nothing and the check reports a clean pass over a file full of conflict markers — the exact defect this tool exists to prevent, shipped inside the tool itself.
|
||||
|
||||
### Then the review turned on the harness, and found three more
|
||||
|
||||
A second independent review ran everything from a fresh clone and reproduced three defects — **all of them in the tools written to prevent defects.** None was in `push-guard.sh`.
|
||||
|
||||
**1. The clean-clone verifier could not verify the tree that ships it.** `verify-clean-clone.sh` resolved the repository top level correctly but then passed **bare basenames** to `git ls-tree`, which is a root-relative pathspec. These files really live at `packages/mosaic/framework/tools/git/`, so in place every artifact came back `NOT TRACKED at HEAD` and the verifier exited 1 without ever running. The tool built to stop packaging false-greens was unusable against its own packaging.
|
||||
|
||||
Its suite could not see it, because **every fixture installed the artifacts at the fixture repository root.** 6/6 green proved flat-layout operation and said nothing about the deployed path. That is the third time on this tool that a control validated a _model_ instead of the _subject_: v1 of the verifier measured a `cp`'d scratch repo, and then v2's own tests measured a layout that does not exist. **A fixture is a claim about the world; an untested fixture is an unreviewed one.** The committed prefix now comes from `git rev-parse --show-prefix` and is threaded through `ls-tree`, the cloned `stat`, and the suite's working directory; `w6-nested` builds the real nested layout, `w6-prefix` asserts the verifier _reports_ that prefix (so a green `w6` cannot mean the prefix was harmlessly ignored), and `w7-nested-mode` proves the mode needle still bites down there.
|
||||
|
||||
**2. Any pre-existing suite failure satisfied every mutant.** The generator called a mutant `KILLED` whenever `failed > 0`, and never required a green baseline. Inject one always-failing case that changes no guard behaviour whatsoever and the run reports _13 killed, 0 survived_, emits the table above, and exits 0. **A tally is not evidence; a named delta is.** The generator now refuses outright unless the unmodified baseline is exit-0 with zero failures — and emits no table when it refuses — then scores each mutant by the _named cases_ that stopped passing, printing the first one (`by: <case>`) beside every kill.
|
||||
|
||||
**3. An interrupted run stranded a mutated `push-guard.sh` in the reviewed tree.** Restoration leaned on an `EXIT` trap. **A trap is cleanup, not isolation, and SIGKILL cannot run it.** It happened to the reviewer twice and contaminated the following suite run until the clone was discarded. Isolation is now by construction: the guard and suite are `install`ed into a temp dir and every mutation is applied to _that_ copy, so the reviewed file is never opened for writing at all.
|
||||
|
||||
Note the deliberate asymmetry with `verify-clean-clone.sh`, which forbids `cp` anywhere in the file. The rule is not "never copy" — it is **know whether the copy preserves the property you are about to measure.** `cp` launders mode, so the verifier must not copy; mutation is destructive by design, so the generator must.
|
||||
|
||||
`test-mutate-push-guard.sh` (8 cases) now covers all three: `g1-*` proves a red baseline is refused with no table, `g2-*` is the positive control plus an assertion that kills are attributed by name, and `g3-*` kills the generator mid-mutation and asserts the subject is byte-identical afterwards.
|
||||
|
||||
That last one was **vacuous on its first attempt.** `timeout -s KILL 3` looked convincing and proved nothing: at three seconds the generator is still running its baseline, so no mutation has been applied and the subject is trivially unchanged — for the _unfixed_ in-place generator too, which I confirmed by rebuilding it and running it. The kill is now driven from inside the run (the fixture's suite counts its own invocations and kills the generator on the second, when mutant #1 is applied), and `g3-needle-bites` puts the reconstructed pre-fix mechanism through the identical kill to prove it _does_ strand a mutated file. A control written to close a blocker was itself a member of the vacuous family.
|
||||
|
||||
Two smaller things fell out of building those cases, both worth recording because both read as the opposite of what they were:
|
||||
|
||||
- **`grep -q` under `pipefail` turns a successful match into a failed assertion.** `grep -q` exits at the first match, the producer dies of SIGPIPE, and `pipefail` reports 141. This cost a red `w6-prefix` against a verifier that was printing the right prefix all along. Capture into a variable and test the variable.
|
||||
- **`$PPID` inside `$( )` is the subshell, not the caller.** Killing it merely ends the command substitution; the parent carries on and exits 0. The fixture uses `kill -9 0` (the process group) with the generator launched under `setsid --wait`.
|
||||
|
||||
**Linting is measured at default severity, and the earlier claim was not.** "shellcheck clean on all five" was published on the strength of `shellcheck -S warning`, which exited 0 — while the default severity exited 1 with twelve `SC2016` findings. A filtered measurement reported as an unfiltered claim is the same shape as everything else on this page. Those literals genuinely must not expand, so `run_mutants()` carries one scoped, documented `SC2016` suppression; all six files are now clean at **default** severity. (A documented "this literal is intentionally unexpanded" is a different thing from a comment asserting a safety property nobody rechecks.)
|
||||
|
||||
**Not independently reproduced here:** blocker 1's original repro ran against the real PR checkout, and this session has no credential for that remote. The `w6`/`w7` fixtures replicate the layout at the exact deployed prefix instead, which is a reconstruction, not the original observation. Stated rather than glossed.
|
||||
|
||||
## Proposed framework path
|
||||
|
||||
```
|
||||
framework/tools/git/push-guard.sh # the guard
|
||||
framework/tools/git/test-push-guard.sh # 46 needles and controls
|
||||
framework/tools/git/mutate-push-guard.sh # regenerates the mutation table above
|
||||
framework/tools/git/test-mutate-push-guard.sh # 8 needles for the generator
|
||||
framework/tools/git/verify-clean-clone.sh # proves the COMMITTED artifact runs
|
||||
framework/tools/git/test-verify-clean-clone.sh # 9 needles for the verifier
|
||||
```
|
||||
|
||||
Matches the existing `tools/git/test-*.sh` convention. Dependencies: bash 4.4+, git, python3 — `python3` is already an accepted dependency of `ci-queue-wait.sh`.
|
||||
|
||||
**All six must be committed mode `100755`.** They were once delivered `100644`, so a clone exited `126 Permission denied` for everyone who was not the author; `verify-clean-clone.sh` exists to make that unshippable and asserts the mode from `git ls-tree` of the source commit, never from the filesystem.
|
||||
|
||||
Operator-agnostic: no hostnames, credentials, remotes, or operator-specific paths. Clean under the framework-PR firewall.
|
||||
+793
@@ -0,0 +1,793 @@
|
||||
#!/usr/bin/env bash
|
||||
# push-guard.sh - Mechanical guards against verifications that PASS when the
|
||||
# thing they verify never happened.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# Three independent incidents, one shape:
|
||||
# 1. An agent's "PUSH VERIFIED" step compared local HEAD to the remote ref and
|
||||
# reported success. The commit had ABORTED, so local trivially equalled
|
||||
# remote. The guard could not distinguish "pushed" from "committed nothing".
|
||||
# 2. An agent chained a push after a FAILED commit, ran on stale state, and
|
||||
# pushed an EMPTY commit carrying an unrelated commit's message.
|
||||
# 3. An agent committed unresolved conflict markers into 70 generated files and
|
||||
# pushed invalid JSON to a default branch.
|
||||
#
|
||||
# All three are the same defect: a check whose success condition is satisfied by
|
||||
# the null case. This script asserts the POSITIVE fact (a new object exists, the
|
||||
# remote MOVED, the content parses) rather than the absence of an error.
|
||||
#
|
||||
# DESIGN RULES (do not relax these)
|
||||
# * Every check exits NON-ZERO and names what failed. There is deliberately no
|
||||
# --warn-only / --soft flag: a guard that can degrade to a warning is the
|
||||
# fail-open we are trying to remove.
|
||||
# * Checks are fail-CLOSED. `set -euo pipefail` means an unexpected git or
|
||||
# network error aborts non-zero rather than falling through to "OK".
|
||||
# * Nothing is skipped silently. A check with no applicable inputs says so on
|
||||
# stdout instead of contributing a quiet green.
|
||||
#
|
||||
# EXIT CODES
|
||||
# 0 all requested checks passed
|
||||
# 2 conflict markers present, or the index has unmerged paths
|
||||
# 3 staged JSON does not parse
|
||||
# 4 push verification failed (remote did not move, or moved elsewhere)
|
||||
# 5 nothing staged / empty commit — the null case, refused
|
||||
# 6 NO JSON DECISION — no --json-path and no usable .push-guard.json
|
||||
# 64 usage error
|
||||
#
|
||||
# Dependencies: bash 4.4+, git, python3.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly EX_OK=0
|
||||
readonly EX_CONFLICT=2
|
||||
readonly EX_JSON=3
|
||||
readonly EX_PUSH=4
|
||||
readonly EX_NULL=5
|
||||
readonly EX_CONFIG=6
|
||||
readonly EX_USAGE=64
|
||||
|
||||
# Repo-level configuration. Its ABSENCE is refused, not defaulted — see
|
||||
# resolve_json_config() for why.
|
||||
readonly CONFIG_FILE=".push-guard.json"
|
||||
|
||||
# Conflict-marker detection.
|
||||
#
|
||||
# We match `<<<<<<<`, `>>>>>>>` and the diff3 `|||||||` marker, each anchored at
|
||||
# column 0 and required to be followed by a space or end-of-line (real markers
|
||||
# are exactly seven characters plus an optional ref label).
|
||||
#
|
||||
# We deliberately do NOT match a bare `=======` line. It is the one marker that
|
||||
# collides with ordinary prose — reStructuredText section underlines and ASCII
|
||||
# rules use it constantly — and a guard that fires on documentation gets switched
|
||||
# off, which costs more than the miss. Every genuine conflict git writes contains
|
||||
# `<<<<<<<` and `>>>>>>>` as well, so requiring those loses no real detection.
|
||||
readonly MARKER_PATTERNS=(
|
||||
-e '^<<<<<<<( |$)'
|
||||
-e '^>>>>>>>( |$)'
|
||||
-e '^[|]{7}( |$)'
|
||||
)
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
fail() {
|
||||
local code="$1"; shift
|
||||
printf '\n' >&2
|
||||
printf 'PUSH-GUARD FAILED: %s\n' "$1" >&2
|
||||
shift
|
||||
local line
|
||||
for line in "$@"; do printf ' %s\n' "$line" >&2; done
|
||||
printf '\n' >&2
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
push-guard.sh check-staged [--json-path <pathspec>]...
|
||||
[--allow-invalid-json <pathspec>]...
|
||||
push-guard.sh push --remote <remote> --branch <branch> [--since-head <sha>]
|
||||
[-- <extra git push args>...]
|
||||
push-guard.sh --help
|
||||
|
||||
Subcommands:
|
||||
check-staged Run pre-commit content guards against the STAGED index:
|
||||
(a) no unmerged index paths, no conflict markers
|
||||
(c) staged JSON under the nominated paths parses
|
||||
Refuses to pass when nothing is staged (exit 5).
|
||||
Check (c) requires an EXPLICIT decision — either --json-path,
|
||||
or .push-guard.json. Saying nothing is refused (exit 6).
|
||||
|
||||
push Perform a push and prove it HAPPENED:
|
||||
- HEAD is a non-empty commit (differs from its parent)
|
||||
- with --since-head: HEAD advanced past that sha
|
||||
- the remote ref MOVED, and now equals local HEAD
|
||||
Capturing the remote ref BEFORE the push is what makes
|
||||
"remote == local" meaningful; without it the assertion is
|
||||
satisfied by having pushed nothing.
|
||||
|
||||
Options:
|
||||
--json-path <pathspec> Git pathspec of generated/serialized JSON to
|
||||
parse-check, e.g. 'data/**/*.json'. Repeatable.
|
||||
Opt-in on purpose: many real .json files are
|
||||
JSONC (tsconfig) or templates and do NOT parse
|
||||
strictly. Nominate the generated ones. Satisfies
|
||||
the decision requirement on its own; overrides a
|
||||
config opt-out (loudly).
|
||||
--allow-invalid-json <pathspec> Exempt a path from the JSON parse check
|
||||
(for deliberate malformed-input fixtures).
|
||||
Exemptions are printed, never silent.
|
||||
--since-head <sha> HEAD before your commit step. Asserts a new
|
||||
commit object was actually created.
|
||||
--remote <name> Remote to push to.
|
||||
--branch <name> Branch to push.
|
||||
|
||||
Repo config (.push-guard.json, at the repository root):
|
||||
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
|
||||
— nominate generated JSON for parse-checking.
|
||||
{"json_check": "none", "reason": "no generated JSON in this repo"}
|
||||
— opt out. The reason is REQUIRED and is printed on every run.
|
||||
|
||||
An opt-out is only accepted from this file, never from a command-line flag: a
|
||||
committed reason can be read in a diff, an absent flag cannot be reviewed at all.
|
||||
An invalid config is refused outright rather than treated as absent.
|
||||
|
||||
There is no flag to downgrade a failure to a warning. That is intentional.
|
||||
EOF
|
||||
}
|
||||
|
||||
require_repo() {
|
||||
git rev-parse --show-toplevel >/dev/null 2>&1 \
|
||||
|| fail "$EX_USAGE" "not inside a git repository"
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- check-staged
|
||||
|
||||
# staged_files_z <array-name> [pathspec...]
|
||||
#
|
||||
# THE FAIL-OPEN THIS FUNCTION EXISTS TO REMOVE:
|
||||
# mapfile -d '' -t files < <(git diff ...)
|
||||
# observes MAPFILE's exit status, never the producer's. When git diff dies -- a
|
||||
# bad pathspec, a corrupt index -- mapfile cheerfully reads zero bytes and
|
||||
# succeeds, and the caller then reports that silence as "nothing to check".
|
||||
#
|
||||
# `set -o pipefail` does NOT cover this. pipefail applies to PIPELINES; a process
|
||||
# substitution is an asynchronous child whose status is never collected. That is
|
||||
# the precise reason every `cmd | cmd` in this file is safe and both `< <(cmd)`
|
||||
# constructs were not -- a distinction worth stating, because "we set pipefail"
|
||||
# reads like whole-file coverage and is not.
|
||||
#
|
||||
# Both callers now route through here, so the category is gone rather than the
|
||||
# two known instances being patched.
|
||||
staged_files_z() {
|
||||
local -n _out="$1"; shift
|
||||
local tmp err status=0
|
||||
tmp="$(mktemp)"; err="$(mktemp)"
|
||||
if (( $# )); then
|
||||
git diff --cached --name-only --diff-filter=ACM --no-renames -z \
|
||||
-- "$@" >"$tmp" 2>"$err" || status=$?
|
||||
else
|
||||
git diff --cached --name-only --diff-filter=ACM --no-renames -z \
|
||||
>"$tmp" 2>"$err" || status=$?
|
||||
fi
|
||||
if (( status != 0 )); then
|
||||
local msg; msg="$(tr '\n' ' ' <"$err")"
|
||||
rm -f "$tmp" "$err"
|
||||
fail "$EX_CONFIG" \
|
||||
"CANNOT ENUMERATE STAGED FILES — git diff exited $status" \
|
||||
"git said: ${msg:-(no message)}" \
|
||||
"" \
|
||||
"Refusing to report a result. An enumeration that failed returns no" \
|
||||
"files, which is indistinguishable from a clean tree — reporting that" \
|
||||
"as OK would be a silent pass on an unexamined index."
|
||||
fi
|
||||
_out=()
|
||||
mapfile -d '' -t _out <"$tmp"
|
||||
rm -f "$tmp" "$err"
|
||||
}
|
||||
|
||||
check_unmerged() {
|
||||
local unmerged
|
||||
unmerged="$(git ls-files --unmerged | awk '{print $4}' | sort -u)"
|
||||
if [[ -n "$unmerged" ]]; then
|
||||
mapfile -t paths <<<"$unmerged"
|
||||
fail "$EX_CONFLICT" \
|
||||
"the index has UNMERGED paths — a merge/rebase is still in progress" \
|
||||
"${paths[@]}" \
|
||||
"" \
|
||||
"Resolve the conflict and 'git add' the result. Do not commit past this."
|
||||
fi
|
||||
log " ok index has no unmerged paths"
|
||||
}
|
||||
|
||||
check_conflict_markers() {
|
||||
local -a files=("$@")
|
||||
local hits
|
||||
# git grep --cached searches the INDEX (what will actually be committed),
|
||||
# not the working tree.
|
||||
#
|
||||
# -a (treat every blob as text), NOT -I (skip binary). -I honours git's
|
||||
# binary classification, which includes an explicit `.gitattributes` `binary`
|
||||
# attribute. A repo that marks a path binary would have its staged conflict
|
||||
# markers silently skipped while the summary still counted the file as
|
||||
# scanned — a clean pass AND a false count. Verified reproducible. The cost
|
||||
# of -a is that a genuine binary blob could theoretically emit a noisy match
|
||||
# line; a false alarm is recoverable, a silent miss is not.
|
||||
# -E is load-bearing: git grep defaults to BASIC regex, in which '(', '|'
|
||||
# and '{7}' are literal characters. Without it these patterns match nothing
|
||||
# and the check reports a clean pass over a file full of markers. That
|
||||
# failure was caught by the needle, not by review.
|
||||
#
|
||||
# Exit status is discriminated, NOT swallowed. git grep returns 1 for "no
|
||||
# match" and >=2 for a real error (bad pathspec, unreadable index). A blanket
|
||||
# '|| true' would turn a broken scan into "ok, no conflict markers" — the
|
||||
# same fail-open in the guard's own error handling.
|
||||
local status=0
|
||||
hits="$(git grep --cached -a -n -E "${MARKER_PATTERNS[@]}" -- "${files[@]}")" || status=$?
|
||||
if (( status > 1 )); then
|
||||
fail "$EX_CONFLICT" \
|
||||
"git grep FAILED (exit $status) — the conflict scan did not run" \
|
||||
"Refusing to report a clean result from a scan that did not complete."
|
||||
fi
|
||||
if [[ -n "$hits" ]]; then
|
||||
mapfile -t lines <<<"$hits"
|
||||
fail "$EX_CONFLICT" \
|
||||
"staged content contains unresolved CONFLICT MARKERS" \
|
||||
"${lines[@]}" \
|
||||
"" \
|
||||
"These are staged and would be committed verbatim."
|
||||
fi
|
||||
log " ok no conflict markers in ${#files[@]} staged file(s)"
|
||||
}
|
||||
|
||||
# JSON parse check.
|
||||
#
|
||||
# SCOPE IS OPT-IN, AND THAT IS A MEASURED DECISION, NOT LAZINESS.
|
||||
# The first draft checked every staged *.json. Run against a real repository that
|
||||
# turned out to be 17 of 119 tracked .json files failing a strict parse — all of
|
||||
# them legitimate: every tsconfig*.json is JSONC (comments are legal there) and
|
||||
# the CA templates are Go templates that merely carry a .json suffix. An
|
||||
# on-by-default check would have fired on ~14% of this repo's JSON, and a guard
|
||||
# that cries wolf gets routed around until the bypass is habitual — which is
|
||||
# worse than no guard, because the bypass then also covers the true positives.
|
||||
#
|
||||
# So the caller nominates the generated/serialized paths this check is FOR, as
|
||||
# git pathspecs (git, not bash, expands them — '**' works correctly).
|
||||
#
|
||||
# WHAT SAYING NOTHING NOW COSTS YOU
|
||||
# ---------------------------------
|
||||
# The first release announced "JSON check NOT REQUESTED" and continued. That was
|
||||
# visible rather than silent, which is better — but it is still an ABSENCE, and
|
||||
# an absence is not reviewable. Nobody reads a line that has printed correctly
|
||||
# ten thousand times. A repo that needed the check and never wired it up stayed
|
||||
# unprotected forever, and nothing ever failed.
|
||||
#
|
||||
# The decision is now MANDATORY and must come from one of two places:
|
||||
# * turning the check ON — --json-path on the command line, or "json_paths"
|
||||
# in .push-guard.json
|
||||
# * turning the check OFF — ONLY "json_check": "none" in .push-guard.json,
|
||||
# which REQUIRES a non-empty "reason"
|
||||
# Saying nothing at all is refused (exit 6).
|
||||
#
|
||||
# The asymmetry is deliberate. Turning a check on is safe from anywhere. Turning
|
||||
# one OFF is confined to a committed file because that is the only form a human
|
||||
# can review: you can read a reason in a diff, and you cannot review the fact
|
||||
# that nobody typed a flag. An opt-out that lives in an ad-hoc command line is
|
||||
# just the old fail-open with extra steps.
|
||||
# The config parser, held as a string rather than an inline heredoc.
|
||||
#
|
||||
# A `<<'PY'` heredoc INSIDE a $( ) command substitution makes bash emit
|
||||
# warning: command substitution: 1 unterminated here-document
|
||||
# on every single run. It still executed, every needle stayed green and the
|
||||
# static linter stayed clean — the warning goes to stderr and broke no
|
||||
# assertion. It surfaced only when the guard was run against a real
|
||||
# repository. A tool built to refuse quiet output was quietly polluting
|
||||
# stderr; needle 'z1' now asserts the guard emits no warnings at all.
|
||||
#
|
||||
# `read -d ''` returns non-zero at EOF without finding a NUL, which is the
|
||||
# NORMAL path when slurping a heredoc — hence `|| true`. That is the one
|
||||
# shape where it does not mask a real error, unlike the `git grep || true`
|
||||
# this codebase already removed once.
|
||||
IFS='' read -r -d '' CONFIG_PARSER <<'PY' || true
|
||||
import json, sys
|
||||
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path) as fh:
|
||||
cfg = json.load(fh)
|
||||
except Exception as exc:
|
||||
sys.stderr.write("does not parse: %s" % exc)
|
||||
sys.exit(2)
|
||||
|
||||
if not isinstance(cfg, dict):
|
||||
sys.stderr.write("must contain a JSON object, got %s" % type(cfg).__name__)
|
||||
sys.exit(2)
|
||||
|
||||
def clean_list(name, value):
|
||||
if not isinstance(value, list):
|
||||
sys.stderr.write('"%s" must be an array' % name)
|
||||
sys.exit(2)
|
||||
for item in value:
|
||||
if not isinstance(item, str) or not item.strip():
|
||||
sys.stderr.write('"%s" must contain only non-empty strings' % name)
|
||||
sys.exit(2)
|
||||
# Tabs/newlines would be mangled by the tab-delimited handoff below.
|
||||
# Reject them explicitly rather than silently truncating a pathspec.
|
||||
if "\t" in item or "\n" in item:
|
||||
sys.stderr.write('"%s" entry contains a tab or newline: %r' % (name, item))
|
||||
sys.exit(2)
|
||||
return value
|
||||
|
||||
mode = cfg.get("json_check")
|
||||
paths = cfg.get("json_paths")
|
||||
allow = cfg.get("allow_invalid_json", [])
|
||||
|
||||
if mode is not None and mode != "none":
|
||||
sys.stderr.write('"json_check" must be "none" if present, got %r' % (mode,))
|
||||
sys.exit(2)
|
||||
|
||||
if mode == "none":
|
||||
if paths:
|
||||
sys.stderr.write('"json_check": "none" and "json_paths" are mutually exclusive')
|
||||
sys.exit(2)
|
||||
reason = cfg.get("reason")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
sys.stderr.write('"json_check": "none" REQUIRES a non-empty "reason"')
|
||||
sys.exit(2)
|
||||
print("MODE\tnone")
|
||||
print("REASON\t%s" % reason.strip().replace("\t", " ").replace("\n", " "))
|
||||
sys.exit(0)
|
||||
|
||||
if paths is None:
|
||||
sys.stderr.write(
|
||||
'states no decision — needs "json_paths", '
|
||||
'or "json_check": "none" with a "reason"'
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
clean_list("json_paths", paths)
|
||||
if not paths:
|
||||
sys.stderr.write('"json_paths" must not be empty')
|
||||
sys.exit(2)
|
||||
clean_list("allow_invalid_json", allow)
|
||||
|
||||
print("MODE\tcheck")
|
||||
for item in paths:
|
||||
print("JPATH\t%s" % item)
|
||||
for item in allow:
|
||||
print("ALLOW\t%s" % item)
|
||||
PY
|
||||
|
||||
resolve_json_config() {
|
||||
local cfg out status=0
|
||||
cfg="$(git rev-parse --show-toplevel)/$CONFIG_FILE"
|
||||
|
||||
if [[ ! -f "$cfg" ]]; then
|
||||
CFG_MODE="absent"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# A malformed config must REFUSE, never degrade to "treat as absent". That
|
||||
# fallback would rebuild precisely the fail-open this gate closes: a typo in
|
||||
# the config would silently disable the check it was written to enable.
|
||||
out="$(python3 -c "$CONFIG_PARSER" "$cfg" 2>&1)" || status=$?
|
||||
|
||||
if (( status != 0 )); then
|
||||
fail "$EX_CONFIG" \
|
||||
"$CONFIG_FILE is INVALID — $out" \
|
||||
"" \
|
||||
"Refusing to run. A config that does not parse is not the same as no" \
|
||||
"config: treating it as absent would silently disable the very check" \
|
||||
"this file was written to enable."
|
||||
fi
|
||||
|
||||
local key val
|
||||
while IFS=$'\t' read -r key val; do
|
||||
case "$key" in
|
||||
MODE) CFG_MODE="$val" ;;
|
||||
REASON) CFG_REASON="$val" ;;
|
||||
JPATH) CFG_PATHS+=("$val") ;;
|
||||
ALLOW) CFG_ALLOW+=("$val") ;;
|
||||
esac
|
||||
done <<<"$out"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# THE ASYMMETRY, ENFORCED RATHER THAN INTENDED.
|
||||
# ON may come from anywhere: turning a check on needs no review.
|
||||
# OFF must come from a COMMITTED object, because the entire argument for
|
||||
# requiring a written reason was that a reason is reviewable in a diff
|
||||
# while an absence is not. An opt-out honoured from an untracked
|
||||
# working-tree file is not reviewable either -- it is an ad-hoc local
|
||||
# bypass wearing a config file's clothes, and it defeats the design.
|
||||
# So the OFF decision is re-read from HEAD and the working tree gets no
|
||||
# vote in it. A committed ON flipped to OFF locally is likewise refused.
|
||||
# ------------------------------------------------------------------
|
||||
[[ "$CFG_MODE" == "none" ]] || return 0
|
||||
|
||||
local rel cmode cblob cout cstatus=0
|
||||
rel="$(git ls-files --full-name --error-unmatch -- "$cfg" 2>/dev/null)" || rel=""
|
||||
if [[ -z "$rel" ]]; then
|
||||
fail "$EX_CONFIG" \
|
||||
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is not tracked in git" \
|
||||
"recorded reason was: ${CFG_REASON:-(none)}" \
|
||||
"" \
|
||||
"The opt-out requires a written reason precisely so it shows up in a" \
|
||||
"diff. An untracked file shows up in nobody's review, so honouring it" \
|
||||
"would rebuild the unreviewable bypass this design exists to prevent." \
|
||||
"" \
|
||||
"Commit $CONFIG_FILE, then run again. Turning the check ON needs no" \
|
||||
"commit; only turning it OFF does."
|
||||
fi
|
||||
|
||||
# A COMMITTED SYMLINK (mode 120000) is a mutable target: the reviewed blob
|
||||
# is a path, and what it points at can change without any diff at all.
|
||||
cmode="$(git ls-tree HEAD -- "$rel" 2>/dev/null | awk '{print $1}')"
|
||||
if [[ -z "$cmode" ]]; then
|
||||
fail "$EX_CONFIG" \
|
||||
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is staged but not yet in HEAD" \
|
||||
"recorded reason was: ${CFG_REASON:-(none)}" \
|
||||
"" \
|
||||
"A staged-but-uncommitted opt-out has not been through review either." \
|
||||
"Commit it first."
|
||||
fi
|
||||
if [[ "$cmode" == "120000" ]]; then
|
||||
fail "$EX_CONFIG" \
|
||||
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is a committed SYMLINK" \
|
||||
"" \
|
||||
"The reviewed object would be a path, not a decision: what it points" \
|
||||
"at can be changed later without producing any diff. Commit the" \
|
||||
"config as a regular file."
|
||||
fi
|
||||
|
||||
cblob="$(git show "HEAD:$rel" 2>/dev/null)" || cblob=""
|
||||
cout="$(printf '%s' "$cblob" | python3 -c "$CONFIG_PARSER" /dev/stdin 2>&1)" || cstatus=$?
|
||||
if (( cstatus != 0 )); then
|
||||
fail "$EX_CONFIG" \
|
||||
"COMMITTED $CONFIG_FILE is INVALID — $cout" \
|
||||
"" \
|
||||
"The working tree opts out, but the committed version does not parse," \
|
||||
"so there is no reviewable decision to honour."
|
||||
fi
|
||||
local cmode_val="" creason=""
|
||||
while IFS=$'\t' read -r key val; do
|
||||
case "$key" in
|
||||
MODE) cmode_val="$val" ;;
|
||||
REASON) creason="$val" ;;
|
||||
esac
|
||||
done <<<"$cout"
|
||||
|
||||
if [[ "$cmode_val" != "none" ]]; then
|
||||
fail "$EX_CONFIG" \
|
||||
"LOCAL-ONLY OPT-OUT — the working tree turns the JSON check OFF but" \
|
||||
"HEAD does not (committed decision: $cmode_val)" \
|
||||
"working-tree reason: ${CFG_REASON:-(none)}" \
|
||||
"" \
|
||||
"An uncommitted edit that disables a committed check is exactly the" \
|
||||
"unreviewable bypass this guard refuses. Commit the change if it is" \
|
||||
"real; revert it if it was a local convenience."
|
||||
fi
|
||||
|
||||
# Print the COMMITTED reason, never the working tree's: the reason anyone
|
||||
# can actually review is the one in the object.
|
||||
CFG_REASON="$creason"
|
||||
}
|
||||
|
||||
check_staged_json() {
|
||||
local -a checked=() skipped=()
|
||||
local f err
|
||||
local -a pathspec=()
|
||||
|
||||
resolve_json_config
|
||||
|
||||
if (( ${#JSON_PATHS[@]} == 0 )); then
|
||||
case "$CFG_MODE" in
|
||||
absent)
|
||||
fail "$EX_CONFIG" \
|
||||
"NO JSON DECISION — no --json-path, and no $CONFIG_FILE" \
|
||||
"" \
|
||||
"This guard will not run without an explicit decision about" \
|
||||
"staged-JSON validation. Create $CONFIG_FILE with EITHER:" \
|
||||
"" \
|
||||
' {"json_paths": ["data/**/*.json"]}' \
|
||||
"" \
|
||||
"or, if this repo genuinely has no generated JSON:" \
|
||||
"" \
|
||||
' {"json_check": "none", "reason": "<why>"}' \
|
||||
"" \
|
||||
"The reason is mandatory so the opt-out is recorded and" \
|
||||
"reviewable in the diff, instead of being an absence nobody sees."
|
||||
;;
|
||||
none)
|
||||
log " -- JSON check OPTED OUT in $CONFIG_FILE — recorded reason: $CFG_REASON"
|
||||
return 0
|
||||
;;
|
||||
check)
|
||||
JSON_PATHS=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
|
||||
ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
|
||||
log " .. JSON paths from $CONFIG_FILE: ${JSON_PATHS[*]}"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# An explicit --json-path turns the check ON, so it satisfies the decision
|
||||
# requirement by itself. If the config opted out, the nomination WINS and
|
||||
# says so loudly — resolving a contradiction toward more checking is the
|
||||
# only safe direction, but silently ignoring a committed opt-out would
|
||||
# hide a real disagreement.
|
||||
case "$CFG_MODE" in
|
||||
none)
|
||||
log " !! $CONFIG_FILE opts out of the JSON check, but --json-path was"
|
||||
log " !! given — honouring the nomination and checking anyway."
|
||||
;;
|
||||
check)
|
||||
JSON_PATHS+=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
|
||||
ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Normalize to :(glob) magic. WITHOUT it, git's default pathspec matching
|
||||
# makes 'data/**/*.json' require at least one intermediate directory, so it
|
||||
# matches data/sub/b.json but SILENTLY SKIPS data/a.json. The obvious
|
||||
# spelling would then deliver partial coverage with no warning — a
|
||||
# quiet-underscan, which is the same family of defect as a quiet pass.
|
||||
# Pathspecs that already carry explicit magic (leading ':') are left alone.
|
||||
for f in "${JSON_PATHS[@]}"; do
|
||||
[[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(glob)$f")
|
||||
done
|
||||
for f in ${ALLOW_INVALID_JSON[@]+"${ALLOW_INVALID_JSON[@]}"}; do
|
||||
[[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(exclude,glob)$f")
|
||||
skipped+=("$f")
|
||||
done
|
||||
|
||||
local -a files=()
|
||||
staged_files_z files "${pathspec[@]}"
|
||||
|
||||
for f in ${files[@]+"${files[@]}"}; do
|
||||
[[ "$f" == *.json ]] || continue
|
||||
|
||||
if ! err="$(git show ":$f" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
json.load(sys.stdin)
|
||||
except Exception as exc:
|
||||
sys.stderr.write(str(exc))
|
||||
sys.exit(1)
|
||||
' 2>&1)"; then
|
||||
fail "$EX_JSON" \
|
||||
"staged JSON does not parse: $f" \
|
||||
"$err" \
|
||||
"" \
|
||||
"A serialization error from a generator is a STOP, not something to commit past."
|
||||
fi
|
||||
checked+=("$f")
|
||||
done
|
||||
|
||||
for f in ${skipped[@]+"${skipped[@]}"}; do
|
||||
log " -- JSON check EXEMPTED by --allow-invalid-json: $f"
|
||||
done
|
||||
if (( ${#checked[@]} == 0 )); then
|
||||
log " -- no staged .json under ${JSON_PATHS[*]} — JSON check not applicable"
|
||||
else
|
||||
log " ok ${#checked[@]} staged .json file(s) under ${JSON_PATHS[*]} parse"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_check_staged() {
|
||||
require_repo
|
||||
log "push-guard: checking staged content"
|
||||
|
||||
check_unmerged
|
||||
|
||||
# --no-renames is load-bearing, NOT cosmetic. Git detects renames by default
|
||||
# (diff.renames=true since 2.9), so a `git mv` plus a small edit is reported
|
||||
# as ONE 'R' entry — which --diff-filter=ACM DOES NOT MATCH. The renamed file
|
||||
# becomes invisible to every content check: staged conflict markers sail
|
||||
# through and the guard prints "staged content OK". Verified reproducible at
|
||||
# 97% similarity with an ordinary co-staged file present. --no-renames
|
||||
# decomposes each rename back into D + A so the new path is always seen.
|
||||
# (Adding R to the filter also works, but --name-only -z emits two paths for
|
||||
# a rename and is ambiguous to parse — this removes the category instead.)
|
||||
#
|
||||
# SECOND INSTANCE OF THE SAME DEFECT, found by sweeping for the construct
|
||||
# rather than fixing only the one that was reported. This one failed CLOSED
|
||||
# (zero files hit "NOTHING IS STAGED"), so it was never a security hole --
|
||||
# but it reported a confident WRONG DIAGNOSIS, sending anyone debugging it
|
||||
# at their index instead of at the failing git invocation.
|
||||
local -a files=()
|
||||
staged_files_z files
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
fail "$EX_NULL" \
|
||||
"NOTHING IS STAGED" \
|
||||
"About to commit, but the index is identical to HEAD." \
|
||||
"" \
|
||||
"This is the null case: a commit here is empty, and any later" \
|
||||
"'verified' step would be asserting against content that does not exist."
|
||||
fi
|
||||
|
||||
check_conflict_markers "${files[@]}"
|
||||
check_staged_json "${files[@]}"
|
||||
|
||||
log "push-guard: staged content OK"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------ push
|
||||
|
||||
remote_sha() {
|
||||
# Empty output means the branch does not exist on the remote yet.
|
||||
# A network/auth failure makes ls-remote non-zero, which set -e turns into an
|
||||
# abort — the guard never treats an unreachable remote as "unchanged".
|
||||
git ls-remote "$1" "refs/heads/$2" | awk 'NR==1{print $1}'
|
||||
}
|
||||
|
||||
cmd_push() {
|
||||
require_repo
|
||||
local remote="" branch="" since_head=""
|
||||
local -a extra=()
|
||||
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--remote) remote="${2:-}"; shift 2 ;;
|
||||
--branch) branch="${2:-}"; shift 2 ;;
|
||||
--since-head) since_head="${2:-}"; shift 2 ;;
|
||||
--) shift; extra=("$@"); break ;;
|
||||
*) fail "$EX_USAGE" "unknown argument to push: $1" ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$remote" && -n "$branch" ]] \
|
||||
|| fail "$EX_USAGE" "push requires --remote and --branch"
|
||||
|
||||
local head
|
||||
head="$(git rev-parse HEAD)"
|
||||
log "push-guard: verifying push of $head -> $remote/$branch"
|
||||
|
||||
# (1) Did a new commit actually get created?
|
||||
if [[ -n "$since_head" ]]; then
|
||||
git rev-parse --verify --quiet "${since_head}^{commit}" >/dev/null \
|
||||
|| fail "$EX_USAGE" \
|
||||
"--since-head is not a commit in this repository: $since_head" \
|
||||
"" \
|
||||
"Cannot verify advancement against a start point that does not exist."
|
||||
|
||||
if [[ "$head" == "$since_head" ]]; then
|
||||
fail "$EX_NULL" \
|
||||
"HEAD DID NOT ADVANCE — no commit was created" \
|
||||
"HEAD is still $head" \
|
||||
"" \
|
||||
"Your commit step failed and execution continued on stale state." \
|
||||
"Pushing now would publish something you did not just build."
|
||||
fi
|
||||
# INEQUALITY IS NOT ADVANCEMENT. "different from where I started" is
|
||||
# satisfied by checking out any unrelated commit -- including one that
|
||||
# existed long before this session -- which would let pre-existing work
|
||||
# be published as something just built. The claim being made is that new
|
||||
# commits were created ON TOP OF the recorded start point, and only
|
||||
# ancestry states that.
|
||||
if ! git merge-base --is-ancestor "$since_head" "$head"; then
|
||||
fail "$EX_NULL" \
|
||||
"HEAD IS NOT A DESCENDANT of the recorded start point" \
|
||||
"start: $since_head" \
|
||||
"head : $head" \
|
||||
"" \
|
||||
"HEAD differs from the start point but does not build on it, so" \
|
||||
"this is a checkout of other history rather than work you just" \
|
||||
"created. Being different is not the same as having advanced."
|
||||
fi
|
||||
log " ok HEAD advanced ${since_head:0:9} -> ${head:0:9} (descendant)"
|
||||
fi
|
||||
|
||||
# (2) Is that commit non-empty?
|
||||
# EXEMPTING BY PARENT COUNT WAS A FAIL-OPEN. An empty root commit sailed
|
||||
# through "emptiness check not applicable" and was then reported as a
|
||||
# CONFIRMED PUSH -- directly contradicting the non-empty requirement this
|
||||
# step exists to enforce. Root and merge commits do have well-defined
|
||||
# emptiness; the original code declined to define it, which is not the same
|
||||
# as it being undefined.
|
||||
local parents
|
||||
parents="$(git rev-list --parents -n 1 HEAD | wc -w)"
|
||||
if (( parents == 2 )); then # sha + exactly one parent
|
||||
if git diff --quiet HEAD^ HEAD; then
|
||||
fail "$EX_NULL" \
|
||||
"HEAD IS AN EMPTY COMMIT — it changes nothing against its parent" \
|
||||
"commit $head" \
|
||||
"" \
|
||||
"An empty commit carrying a real message is indistinguishable" \
|
||||
"from real work in the log. Refusing to push it."
|
||||
fi
|
||||
log " ok HEAD is a non-empty commit"
|
||||
elif (( parents > 2 )); then
|
||||
# A merge is empty when its tree matches EVERY parent: it then carries
|
||||
# no content of its own and no integration either.
|
||||
local p all_same=yes
|
||||
for p in $(git rev-list --parents -n 1 HEAD | cut -d' ' -f2-); do
|
||||
git diff --quiet "$p" HEAD || { all_same=no; break; }
|
||||
done
|
||||
if [[ "$all_same" == yes ]]; then
|
||||
fail "$EX_NULL" \
|
||||
"HEAD IS AN EMPTY MERGE — its tree is identical to every parent" \
|
||||
"commit $head" \
|
||||
"" \
|
||||
"It integrates nothing and introduces nothing. Refusing to push it."
|
||||
fi
|
||||
log " ok HEAD is a non-empty merge commit"
|
||||
else
|
||||
# A root commit has no parent, so emptiness is measured against the
|
||||
# empty tree: it is empty when it adds no paths at all.
|
||||
if [[ -z "$(git diff-tree --root -r --name-only --no-commit-id HEAD)" ]]; then
|
||||
fail "$EX_NULL" \
|
||||
"HEAD IS AN EMPTY ROOT COMMIT — it introduces no files" \
|
||||
"commit $head" \
|
||||
"" \
|
||||
"A root commit is empty when it adds nothing against the empty" \
|
||||
"tree. Refusing to push it."
|
||||
fi
|
||||
log " ok HEAD is a non-empty root commit"
|
||||
fi
|
||||
|
||||
# (3) Capture the remote BEFORE. This is the step whose absence made the
|
||||
# original "PUSH VERIFIED" a false positive.
|
||||
local before after
|
||||
before="$(remote_sha "$remote" "$branch")"
|
||||
log " .. remote before: ${before:-<branch does not exist>}"
|
||||
|
||||
git push "$remote" "HEAD:refs/heads/$branch" ${extra[@]+"${extra[@]}"}
|
||||
|
||||
after="$(remote_sha "$remote" "$branch")"
|
||||
log " .. remote after: ${after:-<absent>}"
|
||||
|
||||
# (4) The remote must now equal local HEAD...
|
||||
if [[ "$after" != "$head" ]]; then
|
||||
fail "$EX_PUSH" \
|
||||
"REMOTE DOES NOT MATCH LOCAL HEAD after push" \
|
||||
"local HEAD: $head" \
|
||||
"remote $branch: ${after:-<absent>}" \
|
||||
"" \
|
||||
"The push did not land what you are holding."
|
||||
fi
|
||||
# (5) ...AND it must have MOVED to get there. Without this, a push that
|
||||
# transferred nothing passes step (4) trivially.
|
||||
if [[ "$after" == "$before" ]]; then
|
||||
fail "$EX_PUSH" \
|
||||
"REMOTE DID NOT MOVE — nothing was actually pushed" \
|
||||
"remote was already at $after before this push ran" \
|
||||
"" \
|
||||
"'remote == local' is satisfied by having pushed nothing. That is" \
|
||||
"the false positive this guard exists to catch: the work you think" \
|
||||
"you just published was already there, or was never committed."
|
||||
fi
|
||||
|
||||
log "push-guard: PUSH CONFIRMED ${before:0:9}${before:+ }-> ${after:0:9}"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------ main
|
||||
|
||||
ALLOW_INVALID_JSON=()
|
||||
JSON_PATHS=()
|
||||
|
||||
CFG_MODE="absent"
|
||||
CFG_REASON=""
|
||||
CFG_PATHS=()
|
||||
CFG_ALLOW=()
|
||||
|
||||
main() {
|
||||
(( $# )) || { usage; exit "$EX_USAGE"; }
|
||||
local sub="$1"; shift
|
||||
case "$sub" in
|
||||
check-staged)
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--json-path) JSON_PATHS+=("${2:-}"); shift 2 ;;
|
||||
--allow-invalid-json) ALLOW_INVALID_JSON+=("${2:-}"); shift 2 ;;
|
||||
*) fail "$EX_USAGE" "unknown argument to check-staged: $1" ;;
|
||||
esac
|
||||
done
|
||||
cmd_check_staged
|
||||
;;
|
||||
push) cmd_push "$@" ;;
|
||||
-h|--help) usage ;;
|
||||
*) usage; exit "$EX_USAGE" ;;
|
||||
esac
|
||||
# Explicit: the only way to reach here is with every requested check passed.
|
||||
exit "$EX_OK"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for ci-queue-wait.sh's 404-branch-absent handling.
|
||||
#
|
||||
# gitea_get_branch_head_sha() resolves a branch's head SHA before the
|
||||
# pre-push queue guard runs. A branch that has never been pushed doesn't
|
||||
# exist on the remote yet, so Gitea's branches/<branch> endpoint 404s.
|
||||
# Before the fix, `curl -fsSL` failed on the 404, its empty stdout was piped
|
||||
# into `python3 -c 'json.load(sys.stdin)'`, and the resulting
|
||||
# JSONDecodeError crashed the guard -- blocking every new feature branch's
|
||||
# first push. The fix must treat 404 as "no in-flight pipeline" (queue
|
||||
# clear) while still failing closed on a genuine API error.
|
||||
#
|
||||
# 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.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-branch-absent}"
|
||||
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" remote add origin https://git.example.test/acme/widgets.git
|
||||
|
||||
# Minimal curl stub. Selects a canned response by inspecting which Gitea
|
||||
# endpoint is being hit (branches/<branch> vs commits/<sha>/status) and
|
||||
# whether -w '%{http_code}' was requested. Only the patched branch-lookup
|
||||
# call passes -w; the unpatched call and the (unchanged) status call both
|
||||
# use plain `curl -fsSL` semantics -- exit nonzero and print nothing on a
|
||||
# non-2xx response. This lets the same stub exercise both the pre-fix and
|
||||
# post-fix branch-lookup code paths faithfully.
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
has_w=0
|
||||
url=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-w) has_w=1 ;;
|
||||
http://*|https://*) url="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$url" in
|
||||
*/branches/*) mode="${MOSAIC_STUB_BRANCH_MODE:?MOSAIC_STUB_BRANCH_MODE not set}" ;;
|
||||
*/status) mode="${MOSAIC_STUB_STATUS_MODE:-terminal-success}" ;;
|
||||
*)
|
||||
echo "curl stub: unrecognized URL: $url" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$mode" in
|
||||
404) code=404; body="" ;;
|
||||
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"}]}' ;;
|
||||
*)
|
||||
echo "curl stub: unknown mode=$mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$has_w" == 1 ]]; then
|
||||
printf '%s\n%s' "$body" "$code"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Unpatched branch-lookup call / status-endpoint call: real curl -fsSL
|
||||
# exits nonzero and emits nothing on stdout for a non-2xx response.
|
||||
if [[ "$code" != "200" ]]; then
|
||||
exit 22
|
||||
fi
|
||||
printf '%s' "$body"
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
run_ci_queue_wait() {
|
||||
local branch="$1"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
export PATH="$STUB_DIR:$PATH"
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
fail=0
|
||||
|
||||
# (a) 404 branch-absent -> queue clear, exit 0.
|
||||
set +e
|
||||
out_a=$(MOSAIC_STUB_BRANCH_MODE=404 run_ci_queue_wait "feat/not-pushed-yet" 2>&1)
|
||||
status_a=$?
|
||||
set -e
|
||||
if [[ "$status_a" -ne 0 ]]; then
|
||||
echo "FAIL(a): expected exit 0 for 404 branch-absent, got $status_a" >&2
|
||||
echo "$out_a" >&2
|
||||
fail=1
|
||||
elif [[ "$out_a" != *"queue clear"* ]]; then
|
||||
echo "FAIL(a): expected a queue-clear message, got:" >&2
|
||||
echo "$out_a" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (b) 200 existing branch + terminal CI state -> unchanged behavior, exit 0.
|
||||
set +e
|
||||
out_b=$(MOSAIC_STUB_BRANCH_MODE=200 MOSAIC_STUB_STATUS_MODE=terminal-success run_ci_queue_wait "main" 2>&1)
|
||||
status_b=$?
|
||||
set -e
|
||||
if [[ "$status_b" -ne 0 ]]; then
|
||||
echo "FAIL(b): expected exit 0 for existing branch with terminal status, got $status_b" >&2
|
||||
echo "$out_b" >&2
|
||||
fail=1
|
||||
elif [[ "$out_b" != *"sha=deadbeefcafef00d0123456789abcdef01234567"* ]]; then
|
||||
echo "FAIL(b): expected the resolved HEAD SHA to be logged, got:" >&2
|
||||
echo "$out_b" >&2
|
||||
fail=1
|
||||
elif [[ "$out_b" == *"queue clear"* ]]; then
|
||||
echo "FAIL(b): an existing branch must not take the branch-absent path" >&2
|
||||
echo "$out_b" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
||||
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
|
||||
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
|
||||
echo "ci-queue-wait branch-absent regression passed (3/3 cases)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/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,232 @@
|
||||
#!/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)"
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
|
||||
# resolution (Gate-16 author≠reviewer separation).
|
||||
#
|
||||
# Covers:
|
||||
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||
# mosaic.gitIdentity (per-worktree) > git-supplied username.
|
||||
# 2. Correct per-slot token file path chosen per host
|
||||
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||
# 3. Per-slot token present -> emits that identity + token.
|
||||
# 4. Per-slot token absent -> falls back to the shared account
|
||||
# (backward-compat / no-op for hosts without per-slot tokens).
|
||||
# 5. Unknown/unrelated host -> exits 0 with no output (passthrough).
|
||||
#
|
||||
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
|
||||
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
|
||||
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
|
||||
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
|
||||
# _lib/credentials.sh, not the real one, to keep this test hermetic.
|
||||
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/git" \
|
||||
"$FAKE_HOME/.config/mosaic/tools/_lib" \
|
||||
"$REPO_DIR"
|
||||
|
||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
||||
chmod +x "$HELPER"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" config user.email "[email protected]"
|
||||
git -C "$REPO_DIR" config user.name "Test"
|
||||
|
||||
# Fake shared-account credential loader — stands in for
|
||||
# tools/_lib/credentials.sh's load_credentials(), scoped to this test only.
|
||||
cat > "$FAKE_HOME/.config/mosaic/tools/_lib/credentials.sh" <<'SH'
|
||||
load_credentials() {
|
||||
case "$1" in
|
||||
gitea-mosaicstack) GITEA_URL="https://git.mosaicstack.dev"; GITEA_TOKEN="shared-mosaicstack-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
||||
gitea-usc) GITEA_URL="https://git.uscllc.com"; GITEA_TOKEN="shared-usc-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
SH
|
||||
|
||||
fail=0
|
||||
assert_eq() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [[ "$expected" != "$actual" ]]; then
|
||||
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# Feed "host=<h>\nusername=<u>\n\n" on stdin (mirrors git's credential protocol)
|
||||
# and run the helper with the fake HOME, inside REPO_DIR (so `git config
|
||||
# mosaic.gitIdentity` resolves per-worktree), plus any extra env passed in $@.
|
||||
run_helper() {
|
||||
local host="$1" username_in="$2"; shift 2
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get <<EOF
|
||||
host=$host
|
||||
username=$username_in
|
||||
|
||||
EOF
|
||||
)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. No identity resolvable anywhere, no per-slot token -> shared fallback
|
||||
# (backward-compat: unchanged behavior when nothing is configured).
|
||||
# ---------------------------------------------------------------------------
|
||||
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=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
|
||||
# that identity + token wins over the shared account.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
|
||||
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=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "git-config beats username: password" "password=agentB-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentC-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentC.token"
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
|
||||
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Identity resolves, but no matching per-slot token file -> falls back to
|
||||
# the shared account (per-agent identity is opt-in, not a hard requirement).
|
||||
# ---------------------------------------------------------------------------
|
||||
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=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
|
||||
# host prefix (gitea-usc- vs gitea-mosaicstack-).
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
||||
out=$(run_helper "git.uscllc.com" "agentD")
|
||||
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
|
||||
# agentD has NO mosaicstack token -> must fall back to shared mosaicstack, not
|
||||
# leak the usc token across hosts.
|
||||
out=$(run_helper "git.mosaicstack.dev" "agentD")
|
||||
assert_eq "host-scoped token path (cross-host must not leak): username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
host=git.mosaicstack.dev
|
||||
username=agentA
|
||||
password=whatever
|
||||
|
||||
EOF
|
||||
)
|
||||
assert_eq "store verb: no output" "" "$store_out"
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "git-credential-mosaic identity resolution regression passed"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -45,6 +45,11 @@ JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "api" ]]; then
|
||||
printf '%s\n' '{"login":"ci-bot"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
|
||||
echo 'GetUserByName: simulated stale login failure' >&2
|
||||
@@ -307,4 +312,901 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
|
||||
fi
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
|
||||
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
|
||||
# bind the credential to the repo host's scheme + host + EFFECTIVE PORT — not the
|
||||
# hostname alone. These fixtures probe the ImportError-dispatched line-parser
|
||||
# fallback under FORCED PyYAML absence with adversarial YAML shapes, asserting it
|
||||
# NEVER misattributes a token from a nested sub-map or a mis-indented line, strips
|
||||
# inline comments like PyYAML, fails closed where PyYAML errors, and rejects a
|
||||
# port mismatch while accepting an exact / default-port match. When PyYAML is
|
||||
# available the same fixtures also assert the PyYAML path agrees (equivalence).
|
||||
# ---------------------------------------------------------------------------
|
||||
FIXTURE_XDG="$WORK_DIR/tokenfix"
|
||||
NOYAML_DIR="$WORK_DIR/noyaml"
|
||||
mkdir -p "$FIXTURE_XDG/tea" "$NOYAML_DIR"
|
||||
# A shadow `yaml` module that raises ImportError, forcing the fallback path.
|
||||
printf 'raise ImportError("forced-absent for #865 fallback regression")\n' > "$NOYAML_DIR/yaml.py"
|
||||
if python3 -c 'import yaml' >/dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi
|
||||
# Confirm the shim really does force ImportError, so the fallback is exercised.
|
||||
if python3 -c 'import yaml' >/dev/null 2>&1; then
|
||||
if PYTHONPATH="$NOYAML_DIR" python3 -c 'import yaml' >/dev/null 2>&1; then
|
||||
echo "FAIL: PyYAML-absence shim did not force ImportError (fallback not exercised)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
||||
|
||||
# Resolve a token via the FORCED-fallback path (PyYAML shimmed to ImportError).
|
||||
token_fallback() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_token_for_login "$1" "$2"
|
||||
' _ "$1" "$2"
|
||||
) 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Resolve a token via the normal path (uses PyYAML when installed).
|
||||
token_pyyaml() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_token_for_login "$1" "$2"
|
||||
' _ "$1" "$2"
|
||||
) 2>/dev/null || true
|
||||
}
|
||||
|
||||
assert_token() {
|
||||
local desc="$1" expected="$2" login="$3" host="$4" got
|
||||
got=$(token_fallback "$login" "$host")
|
||||
if [[ "$got" != "$expected" ]]; then
|
||||
echo "FAIL fallback [$desc]: expected [$expected] got [$got]" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$HAVE_PYYAML" == true ]]; then
|
||||
got=$(token_pyyaml "$login" "$host")
|
||||
if [[ "$got" != "$expected" ]]; then
|
||||
echo "FAIL pyyaml [$desc]: expected [$expected] got [$got]" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 1. Plain, well-formed entry resolves its token.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_PLAIN
|
||||
'
|
||||
assert_token "plain scalar" "TOK_PLAIN" primary git.example
|
||||
|
||||
# 2. A token nested inside a deeper SUB-MAP must NOT attach to the entry — PyYAML
|
||||
# resolves the entry's own token to None here, so the fallback must too.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
extra:
|
||||
token: TOK_NESTED_ATTACKER
|
||||
- name: other
|
||||
url: https://git.example
|
||||
token: TOK_OTHER
|
||||
'
|
||||
assert_token "nested sub-map token is not attributed" "" primary git.example
|
||||
assert_token "sibling entry still resolves its own token" "TOK_OTHER" other git.example
|
||||
|
||||
# 3. A MIS-INDENTED token line (deeper than the entry's fields) must not attach;
|
||||
# PyYAML errors on this shape, so both fail closed.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_MISINDENT
|
||||
'
|
||||
assert_token "mis-indented token fails closed" "" primary git.example
|
||||
|
||||
# 4. A trailing inline comment on a scalar is stripped, exactly as PyYAML does.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_INLINE # trailing note
|
||||
'
|
||||
assert_token "inline comment stripped" "TOK_INLINE" primary git.example
|
||||
|
||||
# 5. A PyYAML-fail-closed case: tab indentation. PyYAML raises a scanner error;
|
||||
# the fallback resolves no token. Both fail closed identically.
|
||||
write_fixture "$(printf 'logins:\n - name: primary\n url: https://git.example\n\ttoken: TOK_TAB\n')"
|
||||
assert_token "tab-indent fails closed like PyYAML" "" primary git.example
|
||||
|
||||
# 6. Host binding is scheme + host + EFFECTIVE PORT, not hostname alone.
|
||||
write_fixture 'logins:
|
||||
- name: ported
|
||||
url: https://git.example:8443
|
||||
token: TOK_PORTED
|
||||
'
|
||||
assert_token "explicit port exact match accepted" "TOK_PORTED" ported git.example:8443
|
||||
assert_token "portless repo host rejects :8443 login" "" ported git.example
|
||||
assert_token "wrong explicit port rejected" "" ported git.example:9443
|
||||
|
||||
# 7. An implicit (portless) login URL equals the scheme's explicit default port.
|
||||
write_fixture 'logins:
|
||||
- name: defported
|
||||
url: https://git.example
|
||||
token: TOK_DEFPORT
|
||||
'
|
||||
assert_token "implicit https vs explicit :443 match" "TOK_DEFPORT" defported git.example:443
|
||||
assert_token "implicit https vs :8443 rejected" "" defported git.example:8443
|
||||
|
||||
# 8. An UNQUOTED token whose raw text PyYAML's implicit resolver types as a
|
||||
# NON-string (int / null / bool / float) must fail closed: PyYAML yields a
|
||||
# non-str value that _accept rejects, so the fallback must NOT surface the
|
||||
# stringified scalar as a credential. Each raw form fails closed IDENTICALLY
|
||||
# to PyYAML (a prior residual emitted "12345"/"null"/"true"/etc. here).
|
||||
assert_nonstring_token_fails_closed() {
|
||||
local desc="$1" raw="$2"
|
||||
write_fixture "logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ${raw}
|
||||
"
|
||||
assert_token "$desc" "" primary git.example
|
||||
}
|
||||
assert_nonstring_token_fails_closed "unquoted int token fails closed" "12345"
|
||||
assert_nonstring_token_fails_closed "unquoted null token fails closed" "null"
|
||||
assert_nonstring_token_fails_closed "unquoted tilde-null token fails closed" "~"
|
||||
assert_nonstring_token_fails_closed "unquoted yes(bool) token fails closed" "yes"
|
||||
assert_nonstring_token_fails_closed "unquoted true(bool) token fails closed" "true"
|
||||
assert_nonstring_token_fails_closed "unquoted float token fails closed" "3.14"
|
||||
|
||||
# 9. A QUOTED scalar is ALWAYS a string, even when its contents look like a
|
||||
# non-string implicit form. The quotes force str typing in PyYAML, so the
|
||||
# fallback must accept the literal (quote-stripped) contents as the token.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: "12345"
|
||||
'
|
||||
assert_token "double-quoted digit token is a literal string" "12345" primary git.example
|
||||
write_fixture "logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: 'abc'
|
||||
"
|
||||
assert_token "single-quoted token is a literal string" "abc" primary git.example
|
||||
|
||||
# assert_fallback_fails_closed: the forced-fallback path MUST resolve no token
|
||||
# (fail closed). Used for STRUCTURAL cases where PyYAML would resolve a DIFFERENT
|
||||
# token (e.g. duplicate-key last-wins) — the fallback must never surface the
|
||||
# wrong/stale token, so it fails closed instead; when PyYAML is present we also
|
||||
# confirm it really does resolve a (divergent) token, proving the fallback is the
|
||||
# strictly-more-conservative side and the case is a genuine fail-open guard.
|
||||
assert_fallback_fails_closed() {
|
||||
local desc="$1" login="$2" host="$3" got
|
||||
got=$(token_fallback "$login" "$host")
|
||||
if [[ -n "$got" ]]; then
|
||||
echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$HAVE_PYYAML" == true ]]; then
|
||||
got=$(token_pyyaml "$login" "$host")
|
||||
if [[ -z "$got" ]]; then
|
||||
echo "FAIL [$desc]: expected PyYAML to resolve a divergent token" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 10. tea's REAL on-disk shape: the `logins:` block SEQUENCE items sit at the
|
||||
# SAME indentation as the key (dash at column 0), with extra scalar fields.
|
||||
# The recognizer must resolve this exactly like PyYAML (regression guard so
|
||||
# the stricter whole-document recognizer does not fail closed on real input).
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_REAL
|
||||
default: false
|
||||
ssh_host: ""
|
||||
- name: other
|
||||
url: https://other.example
|
||||
token: TOK_REAL_OTHER
|
||||
preferences:
|
||||
editor: false
|
||||
flags: null
|
||||
'
|
||||
assert_token "tea dash-at-column-0 real shape resolves" "TOK_REAL" primary git.example
|
||||
assert_token "tea real shape sibling resolves own token" "TOK_REAL_OTHER" other other.example
|
||||
|
||||
# 11. NESTED-SHADOW: a nested `logins:` (NOT at root scope) must not be mistaken
|
||||
# for the real root logins. The recognizer parses whole-document structure,
|
||||
# so it selects the ROOT logins token exactly as PyYAML does — never the
|
||||
# nested attacker token. (A prior line scan matched the FIRST logins at ANY
|
||||
# indent and returned ATTACKER.)
|
||||
write_fixture 'outer:
|
||||
logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ATTACKER_NESTED
|
||||
logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ROOT_TOK
|
||||
'
|
||||
assert_token "nested logins shadow selects ROOT token" "ROOT_TOK" primary git.example
|
||||
|
||||
# 12. BLOCK-SCALAR-SHADOW: text inside a YAML literal/folded block ( | or > ) is
|
||||
# an OPAQUE scalar to PyYAML (so `logins` is a string, not a list) and must
|
||||
# not be scanned as live logins entries. Both fail closed.
|
||||
write_fixture 'logins: |
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ATTACKER_BLOCK
|
||||
'
|
||||
assert_token "block-scalar logins value fails closed" "" primary git.example
|
||||
# A folded/literal block scalar anywhere is outside the recognizer's subset, so
|
||||
# the fallback fails closed (conservative) even though PyYAML can still resolve
|
||||
# the real root token past the opaque scalar. Fail-closed is the safe side.
|
||||
write_fixture 'note: >
|
||||
logins:
|
||||
- name: primary
|
||||
token: ATTACKER_FOLDED
|
||||
logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ROOT_OK
|
||||
'
|
||||
assert_fallback_fails_closed "folded block scalar present fails closed" primary git.example
|
||||
|
||||
# 13. DUPLICATE-ROOT / DUPLICATE-FIELD: a duplicated `logins:` root key (PyYAML
|
||||
# last-wins) or a duplicated field within a login must fail closed rather
|
||||
# than take the FIRST (stale) value. PyYAML resolves the LAST; the fallback
|
||||
# refuses to guess.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: FIRST_DUP
|
||||
logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: LAST_DUP
|
||||
'
|
||||
assert_fallback_fails_closed "duplicate root logins key fails closed" primary git.example
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: FIRST_FIELD
|
||||
token: SECOND_FIELD
|
||||
'
|
||||
assert_fallback_fails_closed "duplicate token field fails closed" primary git.example
|
||||
|
||||
# 14. MALFORMED-AFTER-VALID: a syntax error LATER in the file makes PyYAML reject
|
||||
# the WHOLE document; the recognizer must too (not emit the earlier token).
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_PLAIN
|
||||
broken: a: b: c
|
||||
'
|
||||
assert_token "malformed line after valid login fails closed" "" primary git.example
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_PLAIN
|
||||
broken: [unclosed
|
||||
'
|
||||
assert_token "unclosed flow after valid login fails closed" "" primary git.example
|
||||
|
||||
# 15. EXTRA-DOCUMENT: a multi-document file (--- separator, or ... end marker)
|
||||
# makes PyYAML safe_load reject multi-document input; the recognizer fails
|
||||
# closed on ANY document marker rather than emit the first doc's token.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_PLAIN
|
||||
---
|
||||
logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: SECOND_DOC
|
||||
'
|
||||
assert_token "second document (--- separator) fails closed" "" primary git.example
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_PLAIN
|
||||
...
|
||||
trailing: 1
|
||||
'
|
||||
assert_token "end marker then more content fails closed" "" primary git.example
|
||||
|
||||
# 16. CONSTRUCTOR-VALIDITY / INVALID-INDICATOR: a plain scalar can match a typed
|
||||
# implicit resolver (int/float/timestamp) yet be NON-constructible, or begin
|
||||
# with an indicator a plain scalar may not start with. PyYAML then RAISES on
|
||||
# the WHOLE document (constructor error / scanner error) and yields NO token,
|
||||
# so the fallback must ALSO fail closed for the whole document -- even though
|
||||
# the (unrelated) malformed key sits alongside an otherwise-valid logins
|
||||
# block whose token is itself well-formed. A prior residual proved STRUCTURE
|
||||
# and implicit TYPE but not constructor validity, so it ignored the malformed
|
||||
# key and still emitted the valid login token (fail-open in the dangerous
|
||||
# direction). assert_both_fail_closed asserts fallback == PyYAML == no token.
|
||||
assert_both_fail_closed() {
|
||||
local desc="$1" login="$2" host="$3" got
|
||||
got=$(token_fallback "$login" "$host")
|
||||
if [[ -n "$got" ]]; then
|
||||
echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$HAVE_PYYAML" == true ]]; then
|
||||
got=$(token_pyyaml "$login" "$host")
|
||||
if [[ -n "$got" ]]; then
|
||||
echo "FAIL pyyaml [$desc]: expected PyYAML to also fail closed (raise/no token), got a token" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# write_bad_key_fixture: an unrelated root key carrying $1 as its plain scalar,
|
||||
# followed by an otherwise-valid logins block whose token is well-formed.
|
||||
write_bad_key_fixture() {
|
||||
write_fixture "bad: $1
|
||||
logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_PLAIN
|
||||
"
|
||||
}
|
||||
|
||||
# Non-constructible TIMESTAMP-tagged scalars: match the resolver, but the
|
||||
# calendar field is out of range so PyYAML's datetime construction raises.
|
||||
write_bad_key_fixture '2023-99-99' # month 99 / day 99 invalid
|
||||
assert_both_fail_closed "bad-date 2023-99-99 fails closed like PyYAML" primary git.example
|
||||
write_bad_key_fixture '2023-13-01' # month 13 invalid
|
||||
assert_both_fail_closed "bad-month 2023-13-01 fails closed like PyYAML" primary git.example
|
||||
write_bad_key_fixture '2023-01-15T25:00:00' # hour 25 invalid
|
||||
assert_both_fail_closed "bad-hour timestamp fails closed like PyYAML" primary git.example
|
||||
|
||||
# Non-constructible INT-tagged scalars: match the int resolver, but the radix
|
||||
# body is empty after underscore removal so int(base) raises.
|
||||
write_bad_key_fixture '0b_'
|
||||
assert_both_fail_closed "empty-binary 0b_ fails closed like PyYAML" primary git.example
|
||||
write_bad_key_fixture '0x_'
|
||||
assert_both_fail_closed "empty-hex 0x_ fails closed like PyYAML" primary git.example
|
||||
write_bad_key_fixture '0x__'
|
||||
assert_both_fail_closed "empty-hex 0x__ (multi-underscore) fails closed" primary git.example
|
||||
|
||||
# Invalid plain-scalar INDICATOR forms: a plain scalar may not begin with '%'
|
||||
# (directive) or ',' (flow) -- PyYAML raises a scanner/parser error on the whole
|
||||
# document, so the fallback fails closed on the leading indicator.
|
||||
write_bad_key_fixture '%broken'
|
||||
assert_both_fail_closed "leading-%% directive indicator fails closed" primary git.example
|
||||
write_bad_key_fixture ',bad'
|
||||
assert_both_fail_closed "leading-comma flow indicator fails closed" primary git.example
|
||||
# Bare block indicators in a value position ('-'/'- ', '?'/'? ', ':'/': '):
|
||||
# PyYAML raises a scanner error on the whole document, so the fallback must fail
|
||||
# closed rather than accept the indicator as a plain-scalar string.
|
||||
write_bad_key_fixture '-'
|
||||
assert_both_fail_closed "bare dash (seq indicator) fails closed" primary git.example
|
||||
write_bad_key_fixture '- x'
|
||||
assert_both_fail_closed "dash-space (seq entry) fails closed" primary git.example
|
||||
write_bad_key_fixture '? key'
|
||||
assert_both_fail_closed "question-space (complex key) fails closed" primary git.example
|
||||
# ...but an indicator NOT followed by whitespace is a valid plain scalar string,
|
||||
# so the token still resolves (no over-broad fail-close).
|
||||
write_bad_key_fixture '-x'
|
||||
assert_token "dash-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example
|
||||
write_bad_key_fixture ':x'
|
||||
assert_token "colon-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example
|
||||
|
||||
# NOT over-broad: a genuinely CONSTRUCTIBLE typed scalar (or a look-alike PyYAML
|
||||
# keeps as a plain string) leaves the document valid, so BOTH still resolve the
|
||||
# login token -- the fix must not fail closed on these.
|
||||
write_bad_key_fixture '2023-01-15'
|
||||
assert_token "valid date unrelated key still resolves token" "TOK_PLAIN" primary git.example
|
||||
write_bad_key_fixture '2023-01-15 10:00:00'
|
||||
assert_token "valid datetime unrelated key still resolves token" "TOK_PLAIN" primary git.example
|
||||
# '0o_' is NOT matched by PyYAML's int resolver (YAML 1.1 octal is 0[0-7]+, not
|
||||
# 0o...), so PyYAML keeps it a STRING and resolves the token; the fallback must
|
||||
# agree (no spurious fail-close).
|
||||
write_bad_key_fixture '0o_'
|
||||
assert_token "0o_ is a plain string in PyYAML, token still resolves" "TOK_PLAIN" primary git.example
|
||||
# '4.e8' matches the fallback's (superset) float pattern but PyYAML keeps it a
|
||||
# string; either way it is constructible, so the token still resolves in both.
|
||||
write_bad_key_fixture '4.e8'
|
||||
assert_token "4.e8 float look-alike still resolves token" "TOK_PLAIN" primary git.example
|
||||
# A valid radix int as an unrelated key must not fail closed.
|
||||
write_bad_key_fixture '0x1f'
|
||||
assert_token "valid hex int unrelated key still resolves token" "TOK_PLAIN" primary git.example
|
||||
|
||||
# 17. TAB / SCANNER PARITY: PyYAML raises a ScannerError on a tab used anywhere
|
||||
# outside a quoted scalar -- leading, trailing, or embedded in a plain value,
|
||||
# immediately after a key colon, before a key colon, or as indentation -- and
|
||||
# yields NO token, accepting tabs ONLY inside single/double-quoted scalars
|
||||
# (where the tab is preserved as string content). A prior fallback swallowed
|
||||
# those tabs (via .strip()/.rstrip() normalization and [ \t] key separators)
|
||||
# and still emitted the login token -- a fail-open in the dangerous direction.
|
||||
# The recognizer now fails CLOSED for the whole document on any tab PyYAML
|
||||
# rejects, while preserving the tabs PyYAML keeps (inside quotes). All tab
|
||||
# positions were verified empirically against PyYAML 6.0.3 (ScannerError for
|
||||
# each rejected position; string-preserved for quoted inner tabs).
|
||||
TAB=$'\t'
|
||||
# Fail-close: a tab in a plain value position (trailing / leading / embedded).
|
||||
write_bad_key_fixture "l4o${TAB}"
|
||||
assert_both_fail_closed "trailing tab in plain value fails closed" primary git.example
|
||||
write_bad_key_fixture "${TAB}9"
|
||||
assert_both_fail_closed "leading tab in plain value fails closed" primary git.example
|
||||
write_bad_key_fixture "a${TAB}b"
|
||||
assert_both_fail_closed "embedded tab in plain value fails closed" primary git.example
|
||||
# Fail-close: a tab immediately after the key colon (no separating space).
|
||||
write_fixture "bad:${TAB}9
|
||||
logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_PLAIN
|
||||
"
|
||||
assert_both_fail_closed "tab immediately after key colon fails closed" primary git.example
|
||||
# Fail-close: a tab used as indentation (before a sequence dash).
|
||||
write_fixture "logins:
|
||||
${TAB}- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_PLAIN
|
||||
"
|
||||
assert_both_fail_closed "tab used as indentation fails closed" primary git.example
|
||||
# Fail-close: a tab trailing a sequence-mapping field value.
|
||||
write_fixture "logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_PLAIN${TAB}
|
||||
"
|
||||
assert_both_fail_closed "tab trailing a seq field value fails closed" primary git.example
|
||||
# NOT over-broad: a tab strictly INSIDE a quoted scalar is valid YAML (PyYAML
|
||||
# keeps it as string content), so the document parses and the login token still
|
||||
# resolves in BOTH paths -- double-quoted and single-quoted.
|
||||
write_bad_key_fixture "\"a${TAB}b\""
|
||||
assert_token "tab inside a double-quoted value still resolves token" "TOK_PLAIN" primary git.example
|
||||
write_bad_key_fixture "'a${TAB}b'"
|
||||
assert_token "tab inside a single-quoted value still resolves token" "TOK_PLAIN" primary git.example
|
||||
# ...and a quoted token value carrying an inner tab resolves to the exact string
|
||||
# (tab preserved), identical to PyYAML's construction.
|
||||
write_fixture "logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: \"T${TAB}OK\"
|
||||
"
|
||||
assert_token "quoted token with inner tab resolves verbatim" "T${TAB}OK" primary git.example
|
||||
|
||||
# 18. CONTROL-CHARACTER FAIL-CLOSE (#865 round-9 blocker 1): PyYAML's Reader
|
||||
# scans the ENTIRE raw document stream (not merely scalar contents, and NOT
|
||||
# scoped by quoting) for bytes outside its printable set and raises
|
||||
# ReaderError -- a WHOLE-DOCUMENT reject -- the instant one is found,
|
||||
# regardless of where it sits: an unrelated field's plain scalar, inside a
|
||||
# double- or single-quoted scalar, or a comment. Verified empirically against
|
||||
# real installed PyYAML 6.0.3 (see detect-platform.sh's _FORBIDDEN_CONTROL
|
||||
# comment): every C0 control byte {0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F} plus DEL
|
||||
# (0x7F) rejects in ALL THREE contexts (plain / double-quoted / single-quoted);
|
||||
# only TAB(0x09), LF(0x0A), CR(0x0D) are accepted among the low byte range
|
||||
# (TAB has its own narrower, position-aware coverage in section 17 above; LF/CR
|
||||
# are line separators). A prior fallback ONLY guarded tabs and emitted the
|
||||
# login token from documents PyYAML rejects over an UNRELATED field's control
|
||||
# byte -- a dangerous fail-open (credential emission from a document PyYAML
|
||||
# refuses). write_control_char_fixture writes the raw byte directly via
|
||||
# printf's octal escape (never through a bash string/variable, which cannot
|
||||
# hold an embedded NUL) so 0x00 is exercised faithfully alongside the rest.
|
||||
write_control_char_fixture() {
|
||||
local octal="$1" quote="${2:-}"
|
||||
{
|
||||
if [[ -n "$quote" ]]; then
|
||||
printf 'bad: %sx' "$quote"
|
||||
# shellcheck disable=SC2059 # deliberate: $octal supplies printf's
|
||||
# own \NNN octal escape so the raw control byte reaches the file
|
||||
# directly, never passing through a bash string (which truncates
|
||||
# at an embedded NUL and so cannot represent byte 0x00 otherwise).
|
||||
printf "\\${octal}"
|
||||
printf 'y%s\n' "$quote"
|
||||
else
|
||||
printf 'bad: x'
|
||||
# shellcheck disable=SC2059 # deliberate: $octal supplies printf's
|
||||
# own \NNN octal escape so the raw control byte reaches the file
|
||||
# directly, never passing through a bash string (which truncates
|
||||
# at an embedded NUL and so cannot represent byte 0x00 otherwise).
|
||||
printf "\\${octal}"
|
||||
printf 'y\n'
|
||||
fi
|
||||
printf 'logins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n'
|
||||
} > "$FIXTURE_XDG/tea/config.yml"
|
||||
}
|
||||
|
||||
# Plain (unquoted) unrelated-field placement: the full empirically-confirmed
|
||||
# forbidden C0/DEL set.
|
||||
for octal in 000 001 002 003 004 005 006 007 010 013 014 \
|
||||
016 017 020 021 022 023 024 025 026 027 \
|
||||
030 031 032 033 034 035 036 037 177; do
|
||||
write_control_char_fixture "$octal"
|
||||
assert_both_fail_closed "control byte \\$octal in unrelated plain field fails closed" primary git.example
|
||||
done
|
||||
|
||||
# Inside-quote variants (double and single) for the six bytes called out
|
||||
# explicitly in the round-9 blocker report: 0x00,0x01,0x07,0x0e,0x1f,0x7f.
|
||||
for octal in 000 001 007 016 037 177; do
|
||||
write_control_char_fixture "$octal" '"'
|
||||
assert_both_fail_closed "control byte \\$octal inside double-quoted unrelated field fails closed" primary git.example
|
||||
write_control_char_fixture "$octal" "'"
|
||||
assert_both_fail_closed "control byte \\$octal inside single-quoted unrelated field fails closed" primary git.example
|
||||
done
|
||||
|
||||
# Same forbidden byte inside a comment line -- PyYAML's Reader check is
|
||||
# stream-wide, so it rejects here too, not merely inside live scalar content.
|
||||
{
|
||||
printf '# note'
|
||||
printf '\007'
|
||||
printf 'here\nlogins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n'
|
||||
} > "$FIXTURE_XDG/tea/config.yml"
|
||||
assert_both_fail_closed "control byte in a comment line fails closed" primary git.example
|
||||
|
||||
# NOT over-broad: TAB/LF/CR remain accepted where PyYAML already accepts them
|
||||
# (covered by section 17's tab fixtures and the ordinary newline-delimited
|
||||
# fixtures used throughout this file), so no additional assertion is needed
|
||||
# here beyond confirming the forbidden-control guard does not fire on them.
|
||||
|
||||
# 19. UNSIGNED-EXPONENT FLOAT OVER-REJECTION (#865 round-9 blocker 2): PyYAML
|
||||
# 6.0.3's implicit float resolver requires an EXPLICIT SIGN on the exponent
|
||||
# ([eE][-+][0-9]+); an unsigned exponent is NOT matched, so PyYAML resolves
|
||||
# the scalar as a plain STRING, not a float. A prior fallback's float
|
||||
# recognizer accepted an OPTIONAL sign ([eE][-+]?[0-9]+), over-matching these
|
||||
# spellings as floats and dropping the token PyYAML would emit verbatim
|
||||
# (over-rejection). Verified empirically against real PyYAML 6.0.3.
|
||||
for form in '1.0e10' '+1.0e10' '-1.0e10' '1.0E10' '.5e10' '4.e8'; do
|
||||
write_fixture "logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ${form}
|
||||
"
|
||||
assert_token "unsigned-exponent form '$form' is a PyYAML string, token resolves" "$form" primary git.example
|
||||
done
|
||||
|
||||
# Parity guard: a genuine SIGNED-exponent float is still typed as a non-string
|
||||
# float by PyYAML and must still fail closed (not regress into over-acceptance).
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: 1.0e+10
|
||||
'
|
||||
assert_token "signed-exponent genuine float still fails closed" "" primary git.example
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: 1.0e-10
|
||||
'
|
||||
assert_token "signed-exponent (negative) genuine float still fails closed" "" primary git.example
|
||||
|
||||
# 20. RESIDUAL OVER-REJECTION found via round-9 differential fuzzing (folded into
|
||||
# this round, not split off): a plain scalar starting with "?" NOT followed by
|
||||
# whitespace (e.g. "?x") is a valid PyYAML string -- only a bare "?" or "? "
|
||||
# (question mark followed by space/EOL) opens a complex mapping key and is
|
||||
# illegal in a value position. A prior blanket-reject set treated EVERY
|
||||
# leading "?" as illegal, over-rejecting a token PyYAML accepts verbatim.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ?x
|
||||
'
|
||||
assert_token "question-mark-not-space is a plain string, token resolves" "?x" primary git.example
|
||||
|
||||
# 21. PRINTABLE-BOUNDARY FAIL-CLOSE (#865 round-10 blocker 1): the round-9 guard
|
||||
# used a hand-rolled C0/DEL subset that MISSED code points PyYAML's Reader
|
||||
# also rejects -- the C1 block (U+0080-0084, U+0086-009F) and the BMP
|
||||
# noncharacters U+FFFE/U+FFFF -- so the fallback still emitted the token from
|
||||
# documents PyYAML rejects whole (fail-open). The guard now uses PyYAML
|
||||
# 6.0.3's EXACT Reader.NON_PRINTABLE character class (see detect-platform.sh
|
||||
# _FORBIDDEN_CONTROL). Verified empirically against real PyYAML 6.0.3:
|
||||
# PRINTABLE = {0x09,0x0A,0x0D, 0x20-0x7E, 0x85(NEL), 0xA0-0xD7FF,
|
||||
# 0xE000-0xFFFD, 0x10000-0x10FFFF}; everything else fails the whole document
|
||||
# closed. write_codepoint_fixture emits a chosen Unicode code point's real
|
||||
# UTF-8 bytes (via python3, since bash strings cannot faithfully carry many
|
||||
# of these) into a selectable position, then the login block follows.
|
||||
write_codepoint_fixture() {
|
||||
# $1 = hex code point (e.g. 0x80); $2 = position: field|comment|token|nelterm
|
||||
CP_HEX="$1" CP_POS="$2" python3 - "$FIXTURE_XDG/tea/config.yml" <<'PY'
|
||||
import sys
|
||||
cp = int(__import__("os").environ["CP_HEX"], 16)
|
||||
pos = __import__("os").environ["CP_POS"]
|
||||
ch = chr(cp)
|
||||
head = "logins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n"
|
||||
if pos == "field":
|
||||
doc = head + "other: x" + ch + "y\n"
|
||||
elif pos == "comment":
|
||||
doc = head + "# note x" + ch + "y here\n"
|
||||
elif pos == "token":
|
||||
doc = "logins:\n - name: primary\n url: https://git.example\n token: T" + ch + "K\n"
|
||||
elif pos == "nelterm":
|
||||
# NEL (U+0085) used as the line terminator throughout: PyYAML treats it as a
|
||||
# line break (printable, NOT a ReaderError) and resolves the token; the
|
||||
# fallback's _split_logical_lines splits on NEL identically OUTSIDE a quote
|
||||
# -> parity, token resolves. (Round 23 covers NEL/LS/PS INSIDE a quote, where
|
||||
# PyYAML folds rather than breaks and a naive splitlines() would over-split.)
|
||||
doc = ("logins:" + ch + " - name: primary" + ch
|
||||
+ " url: https://git.example" + ch + " token: TOK_NEL" + ch)
|
||||
else:
|
||||
raise SystemExit("bad pos")
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as f:
|
||||
f.write(doc)
|
||||
PY
|
||||
}
|
||||
|
||||
# C1-block + BMP-noncharacter code points fail the WHOLE document closed in an
|
||||
# unrelated field and in a comment, exactly as PyYAML's ReaderError does.
|
||||
for cphex in 0x80 0x81 0x84 0x86 0x9f 0xfffe 0xffff; do
|
||||
write_codepoint_fixture "$cphex" field
|
||||
assert_both_fail_closed "code point $cphex in unrelated field fails closed" primary git.example
|
||||
write_codepoint_fixture "$cphex" comment
|
||||
assert_both_fail_closed "code point $cphex in a comment fails closed" primary git.example
|
||||
done
|
||||
|
||||
# NOT over-broad: printable code points PyYAML ACCEPTS must still resolve the
|
||||
# token in BOTH paths -- NEL(0x85) as a line separator, U+00A0 (NBSP) inside a
|
||||
# value, and an astral code point (U+1F600) inside the token value.
|
||||
write_codepoint_fixture 0x85 nelterm
|
||||
assert_token "NEL (U+0085) line-terminator resolves token" "TOK_NEL" primary git.example
|
||||
write_codepoint_fixture 0xa0 field
|
||||
assert_token "U+00A0 in unrelated value still resolves token" "TOK_PLAIN" primary git.example
|
||||
write_codepoint_fixture 0x1f600 token
|
||||
assert_token "astral U+1F600 inside token resolves verbatim" "$(printf 'T\360\237\230\200K')" primary git.example
|
||||
|
||||
# 22. INTERNAL-INDICATOR OVER-REJECTION (#865 round-10 blocker 2): the round-9
|
||||
# recognizer blanket-rejected any plain scalar CONTAINING a flow indicator
|
||||
# ([]{}*&!), but in BLOCK context PyYAML treats ',[]{}' as ordinary content
|
||||
# and treats '!&*#...' as significant ONLY at the FIRST non-space char. So an
|
||||
# INTERNAL indicator is legal plain-string content and PyYAML emits the token
|
||||
# verbatim; the fallback dropped it (over-rejection). Verified empirically
|
||||
# against real PyYAML 6.0.3. The fix removes the blanket internal scan while
|
||||
# the leading-char guard and the ' #'/': '/trailing-':' guards keep the
|
||||
# fail-OPEN direction shut.
|
||||
for tv in 'a!b' 'a,b' 'a[b' 'a]b' 'a{b' 'a}b' 'a&b' 'a*b' 'a[b]c' 'a{b}c' 'a,b,c' 'a#b' 'a:b'; do
|
||||
write_fixture "logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ${tv}
|
||||
"
|
||||
assert_token "internal-indicator token '$tv' resolves verbatim" "$tv" primary git.example
|
||||
done
|
||||
|
||||
# Fail-OPEN direction stays shut: a LEADING indicator, a ' #' comment tail, an
|
||||
# internal ': ' (colon-space) inline map, and a trailing ':' each make PyYAML
|
||||
# resolve NO usable string token (tag/flow/anchor reject or None, comment strip,
|
||||
# or a mapping), so BOTH must fail closed. (Leading tag/anchor/flow are the
|
||||
# documented, round-9-approved structural fail-closed class; kept intact here.)
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: !x
|
||||
'
|
||||
assert_both_fail_closed "leading '!' tag token fails closed" primary git.example
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: [a]
|
||||
'
|
||||
assert_both_fail_closed "leading '[' flow-seq token fails closed" primary git.example
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: a # trailing comment
|
||||
'
|
||||
# ' #' comment tail: PyYAML strips the comment -> token is the string 'a', which
|
||||
# still resolves. This is the NOT-over-broad boundary partner of the guard.
|
||||
assert_token "space-hash comment tail strips to plain token" "a" primary git.example
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: a: b
|
||||
'
|
||||
assert_both_fail_closed "internal colon-space (inline map) token fails closed" primary git.example
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ab:
|
||||
'
|
||||
assert_both_fail_closed "trailing colon (map indicator) token fails closed" primary git.example
|
||||
|
||||
# 23. EMBEDDED LINE-BREAK INSIDE A QUOTED SCALAR (#865 round-11 blocker A): the
|
||||
# round-10 fallback split the raw document with str.splitlines(), which breaks
|
||||
# at NEL(U+0085), LS(U+2028) and PS(U+2029) -- code points that are PRINTABLE
|
||||
# to PyYAML's Reader. Inside a flow (quoted) scalar PyYAML does NOT break at
|
||||
# these: it LINE-FOLDS a double/single-quoted scalar (NEL/LF/CR -> a single
|
||||
# space; LS/PS -> the char verbatim), so it resolves ONE token, while
|
||||
# splitlines() cut the value mid-quote and failed the whole document closed
|
||||
# (over-rejection). The fallback now uses _split_logical_lines, which
|
||||
# reproduces PyYAML's flow-folding. Verified empirically vs real PyYAML 6.0.3.
|
||||
# write_break_fixture emits a chosen break code point in a selectable context.
|
||||
write_break_fixture() {
|
||||
# $1 = hex code point of the break; $2 = context: dq|sq|plain|comment|dq2
|
||||
CP_HEX="$1" Q_STYLE="$2" python3 - "$FIXTURE_XDG/tea/config.yml" <<'PY'
|
||||
import sys, os
|
||||
cp = int(os.environ["CP_HEX"], 16)
|
||||
q = os.environ["Q_STYLE"]
|
||||
ch = chr(cp)
|
||||
head = "logins:\n - name: primary\n url: https://git.example\n token: "
|
||||
if q == "dq":
|
||||
doc = head + '"tok' + ch + 'en"'
|
||||
elif q == "sq":
|
||||
doc = head + "'tok" + ch + "en'"
|
||||
elif q == "plain":
|
||||
doc = head + "tok" + ch + "en"
|
||||
elif q == "dq2":
|
||||
# blank line inside a quoted scalar: PyYAML folds a two-break run to a literal
|
||||
# newline, which the recognizer's key regex cannot carry -> endorsed
|
||||
# fail-closed over-reject (see assert_fallback_fails_closed below).
|
||||
doc = head + '"tok' + ch + ch + 'en"'
|
||||
elif q == "comment":
|
||||
doc = ("logins:\n - name: primary\n url: https://git.example\n"
|
||||
" token: TOK_PLAIN\n# c" + ch + "x")
|
||||
else:
|
||||
raise SystemExit("bad q")
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as f:
|
||||
f.write(doc + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
# NEL folds to a single space inside double- AND single-quoted scalars: the token
|
||||
# resolves identically in both paths (fallback no longer over-splits).
|
||||
write_break_fixture 0x85 dq
|
||||
assert_token "NEL inside double-quote folds to space, token resolves" "tok en" primary git.example
|
||||
write_break_fixture 0x85 sq
|
||||
assert_token "NEL inside single-quote folds to space, token resolves" "tok en" primary git.example
|
||||
# LS(U+2028)/PS(U+2029) are preserved VERBATIM by PyYAML's flow fold (they are
|
||||
# not \n-class breaks); the fallback must surface them byte-for-byte.
|
||||
write_break_fixture 0x2028 dq
|
||||
assert_token "LS inside double-quote is verbatim" "$(printf 'tok\342\200\250en')" primary git.example
|
||||
write_break_fixture 0x2028 sq
|
||||
assert_token "LS inside single-quote is verbatim" "$(printf 'tok\342\200\250en')" primary git.example
|
||||
write_break_fixture 0x2029 dq
|
||||
assert_token "PS inside double-quote is verbatim" "$(printf 'tok\342\200\251en')" primary git.example
|
||||
|
||||
# Direction-sensitivity: the SAME code points UNQUOTED (a plain scalar) or in a
|
||||
# COMMENT make PyYAML raise a scanner error, so both paths must fail closed. The
|
||||
# fold rule applies ONLY inside a quoted scalar.
|
||||
write_break_fixture 0x85 plain
|
||||
assert_token "NEL in an unquoted plain scalar fails closed" "" primary git.example
|
||||
write_break_fixture 0x2028 plain
|
||||
assert_token "LS in an unquoted plain scalar fails closed" "" primary git.example
|
||||
write_break_fixture 0x85 comment
|
||||
assert_token "NEL in a comment fails closed" "" primary git.example
|
||||
|
||||
# Endorsed fail-closed over-reject: a blank line inside a quoted scalar folds to a
|
||||
# literal newline that the recognizer cannot carry -- PyYAML resolves a
|
||||
# (newline-bearing) token, the fallback fails closed (strictly safer).
|
||||
write_break_fixture 0x85 dq2
|
||||
assert_fallback_fails_closed "blank-line-in-quote (NEL run) fails closed" primary git.example
|
||||
|
||||
# 24. LEADING NON-SPECIFIC TAG / ANCHOR PROPERTY (#865 round-11 blocker B, revised
|
||||
# in round 12): the round-10 recognizer blanket-rejected any scalar beginning
|
||||
# with '!' or '&'. Round 11 taught it to strip a transparent NON-SPECIFIC tag
|
||||
# ('! ' bang + SPACE) and a transparent plain ANCHOR ('&name ') so '! x' /
|
||||
# '&a x' resolve the STRING 'x', matching PyYAML. Round 12 discovered that the
|
||||
# anchor half of that was a HIGH fail-open: PyYAML's Composer tracks anchor
|
||||
# NAMES in a document-scoped registry and raises ComposerError ("found
|
||||
# duplicate anchor") the instant the SAME name is declared on a SECOND node
|
||||
# ANYWHERE in the document (even an unrelated one) -- the fallback's
|
||||
# per-scalar-only view has no such registry and would emit the later token.
|
||||
# Round 12's fix: reject EVERY '&'-anchor property, unconditionally. The
|
||||
# transparent NON-SPECIFIC TAG behavior ('! x' -> 'x') is unchanged and still
|
||||
# verified below; only the anchor half now fails closed (deliberate
|
||||
# conservative over-reject, verified safe both ways against real PyYAML 6.0.3).
|
||||
for pair in '! x=x' '! x y=x y' '! "q"=q' "! 'q'=q"; do
|
||||
tv="${pair%%=*}"; want="${pair#*=}"
|
||||
write_fixture "logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ${tv}
|
||||
"
|
||||
assert_token "tag property token '$tv' resolves node string" "$want" primary git.example
|
||||
done
|
||||
|
||||
# Fail-OPEN direction stays shut. '!x' (bang + NON-space) is a tag HANDLE ->
|
||||
# ConstructorError; '!foo x'/'* a'/'! !x' raise; a property over a NON-string node
|
||||
# ('! 123'/'! true'/'! null') types non-str -> no usable token. All fail closed in
|
||||
# BOTH paths.
|
||||
for tv in '!x' '!foo x' '* a' '! !x' '! 123' '! true' '! null' '&a &b x'; do
|
||||
write_fixture "logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ${tv}
|
||||
"
|
||||
assert_token "non-resolving property token '$tv' fails closed" "" primary git.example
|
||||
done
|
||||
|
||||
# Round 12: a SINGLE, non-duplicated '&a x' is valid YAML that real PyYAML
|
||||
# resolves to the string 'x' (round-11 behavior, and still true of the oracle).
|
||||
# The fallback now rejects it anyway -- a deliberate, endorsed CONSERVATIVE
|
||||
# over-reject (see the round-12 comment block above): fail-closed can only cost
|
||||
# an emitted token PyYAML would have allowed, never emit one PyYAML rejects, and
|
||||
# a per-scalar recognizer cannot safely prove document-wide anchor-name
|
||||
# uniqueness. assert_fallback_fails_closed also confirms PyYAML really does
|
||||
# resolve a token here, proving this is a genuine (safe-direction) divergence
|
||||
# and not an accidental parity loss.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: &a x
|
||||
'
|
||||
assert_fallback_fails_closed "round-12: single non-duplicated anchor '&a x' now fails closed (conservative over-reject; PyYAML resolves x)" primary git.example
|
||||
# Same over-reject for the combined tag+anchor forms round 11 used to resolve.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ! &b x
|
||||
'
|
||||
assert_fallback_fails_closed "round-12: '! &b x' (tag+anchor) now fails closed (conservative over-reject)" primary git.example
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: &b ! x
|
||||
'
|
||||
assert_fallback_fails_closed "round-12: '&b ! x' (anchor+tag) now fails closed (conservative over-reject)" primary git.example
|
||||
|
||||
# Endorsed fail-closed over-reject: an EXPLICIT tag ('!!str x', verbose
|
||||
# '!<tag:yaml.org,2002:str> x') forces a string PyYAML resolves, but the fallback
|
||||
# recognizes only the transparent non-specific tag and fails closed (safer).
|
||||
for tv in '!!str x' '!<tag:yaml.org,2002:str> x'; do
|
||||
write_fixture "logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ${tv}
|
||||
"
|
||||
assert_fallback_fails_closed "explicit-tag token '$tv' fails closed" primary git.example
|
||||
done
|
||||
|
||||
# 25. #865 round 12 HIGH fail-open closure: DUPLICATE ANCHOR NAME across separate
|
||||
# nodes. Real PyYAML's Composer tracks anchor names in a DOCUMENT-SCOPED
|
||||
# registry and raises ComposerError ("found duplicate anchor ... first
|
||||
# occurrence") the instant the SAME anchor name is declared a second time
|
||||
# ANYWHERE in the document -- failing the WHOLE document closed, no token,
|
||||
# regardless of how far the duplicate sits from the logins block. The round-11
|
||||
# fallback tracked anchors only WITHIN a single scalar's `_strip_properties`
|
||||
# call, so it had no visibility into a duplicate declared on an unrelated
|
||||
# node and would still emit the (later) token: fail-open. The round-12 fix
|
||||
# (reject every '&'-anchor property, unconditionally -- see section 24 above)
|
||||
# closes this as a strict superset: since NO anchor is ever accepted, a
|
||||
# duplicate anchor can never slip through. These cases exercise that
|
||||
# document-wide duplicate-anchor invariant specifically (as opposed to
|
||||
# section 24's single-anchor-on-the-token-field cases) and pair each fallback
|
||||
# assertion with confirmation that real PyYAML also fails closed here (via
|
||||
# ComposerError), proving this was a genuine fail-open, not a hypothetical.
|
||||
write_fixture 'first: &same one
|
||||
second: &same two
|
||||
logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_DUP_ROOT
|
||||
'
|
||||
assert_both_fail_closed "round-12: duplicate anchor name on two unrelated root nodes fails closed" primary git.example
|
||||
|
||||
write_fixture 'outer:
|
||||
nested: &dup x
|
||||
dup_root: &dup y
|
||||
logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: TOK_DUP_NESTED
|
||||
'
|
||||
assert_both_fail_closed "round-12: duplicate anchor name across a nested node and a root node fails closed" primary git.example
|
||||
|
||||
write_fixture 'first: &dup one
|
||||
logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: &dup TOK_DUP_TOKEN_NODE
|
||||
'
|
||||
assert_both_fail_closed "round-12: anchor name declared earlier and repeated on the token-bearing node fails closed" primary git.example
|
||||
|
||||
# Regression guard: the non-specific TAG half of section 24 ('! x' -> 'x') is
|
||||
# UNCHANGED by the round-12 anchor fix and must still resolve.
|
||||
write_fixture 'logins:
|
||||
- name: primary
|
||||
url: https://git.example
|
||||
token: ! x
|
||||
'
|
||||
assert_token "round-12 regression: '! x' (tag, no anchor) still resolves 'x'" "x" primary git.example
|
||||
|
||||
echo "Gitea login resolution regression harness passed"
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for detect-platform.sh's get_gitea_token() per-agent
|
||||
# identity resolution (Gate-16 author≠reviewer separation) — the API-tooling
|
||||
# counterpart to git-credential-mosaic, so pr-create.sh/issue-create.sh/etc.
|
||||
# open records under the resolved agent identity, not the shared account.
|
||||
#
|
||||
# Covers:
|
||||
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
|
||||
# mosaic.gitIdentity (per-worktree).
|
||||
# 2. Correct per-slot token file path chosen per host
|
||||
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
|
||||
# 3. Per-slot token present -> that token is returned (agent-authored calls).
|
||||
# 4. No identity requested -> shared credential-loader token (backward
|
||||
# compat, unchanged).
|
||||
# 5. Patch 2b — explicit identity + recognized Gitea host + ABSENT per-slot
|
||||
# token for that identity -> FAIL LOUD (nonzero return, empty stdout, a
|
||||
# stderr diagnostic naming identity/source/host/expected path). Must NOT
|
||||
# fall through to the shared/default token (Gate-16 author≠reviewer
|
||||
# integrity — never silently borrow another slot's credentials). Covered
|
||||
# for both identity sources (git config, MOSAIC_GIT_IDENTITY env) and
|
||||
# for a same-identity cross-host case (token exists for one host, not
|
||||
# the other).
|
||||
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
|
||||
# per-slot token scheme) -> Patch 2b does not apply; existing
|
||||
# fall-through behavior is unchanged.
|
||||
#
|
||||
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
|
||||
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
|
||||
FAKE_HOME="$WORK_DIR/home"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$REPO_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{
|
||||
"gitea": {
|
||||
"mosaicstack": {
|
||||
"url": "https://git.mosaicstack.dev",
|
||||
"token": "shared-mosaicstack-token"
|
||||
},
|
||||
"usc": {
|
||||
"url": "https://git.uscllc.com",
|
||||
"token": "shared-usc-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
fail=0
|
||||
assert_eq() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if [[ "$expected" != "$actual" ]]; then
|
||||
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# Runs get_gitea_token for $1=host inside REPO_DIR (per-worktree git config
|
||||
# resolves there) with a fake HOME + the stub credentials.json, plus any
|
||||
# extra env passed in $@.
|
||||
call_get_gitea_token() {
|
||||
local host="$1"; shift
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
# shellcheck disable=SC2016 # deliberately deferred: $DETECT_PLATFORM_SH is
|
||||
# expanded by the INNER bash -c (via the exported env var below), not here.
|
||||
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
|
||||
bash -c 'source "$DETECT_PLATFORM_SH"; get_gitea_token "$1"' _ "$host"
|
||||
)
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. No identity resolvable -> shared credential-loader token (unchanged).
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev")
|
||||
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
|
||||
# token -> that token wins over the shared account.
|
||||
# ---------------------------------------------------------------------------
|
||||
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")
|
||||
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
|
||||
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentB)
|
||||
assert_eq "env beats git-config identity token" "agentB-mosaicstack-token" "$out"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. FAIL LOUD (Patch 2b): an identity is explicitly requested (via git config
|
||||
# mosaic.gitIdentity, and separately via MOSAIC_GIT_IDENTITY env) for a
|
||||
# RECOGNIZED Gitea host, but no per-slot token exists for THAT identity.
|
||||
# Must NOT fall through to the shared/default token — silently borrowing
|
||||
# another slot's credentials would post PRs/issues/reviews as the WRONG
|
||||
# agent (Gate-16 author≠reviewer integrity break). Expect: nonzero return,
|
||||
# EMPTY stdout (no token — shared or otherwise — leaked), and a stderr
|
||||
# diagnostic naming the identity, its source, the host, and the expected
|
||||
# per-slot token path.
|
||||
# ---------------------------------------------------------------------------
|
||||
assert_failloud() {
|
||||
local desc="$1" host="$2" ident="$3" expected_tok_path="$4"; shift 4
|
||||
local stderr_file="$WORK_DIR/stderr.tmp"
|
||||
: > "$stderr_file"
|
||||
set +e
|
||||
local stdout
|
||||
stdout=$(call_get_gitea_token "$host" "$@" 2>"$stderr_file")
|
||||
local rc=$?
|
||||
set -e
|
||||
local stderr
|
||||
stderr=$(cat "$stderr_file")
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL: $desc — expected nonzero return, got 0 (stdout='$stdout')" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ -n "$stdout" ]]; then
|
||||
echo "FAIL: $desc — expected empty stdout (no token leaked), got '$stdout'" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" != *"$ident"* ]]; then
|
||||
echo "FAIL: $desc — stderr does not name the requested identity '$ident':" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$stderr" != *"$host"* ]]; then
|
||||
echo "FAIL: $desc — stderr does not name the host '$host':" >&2
|
||||
echo "$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"*"token"* ]]; then
|
||||
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
|
||||
echo "$stderr" >&2
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
# 4a. git config mosaic.gitIdentity source, recognized host (mosaicstack),
|
||||
# shared token IS present but must not be borrowed.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
|
||||
assert_failloud "fail-loud via git-config identity (recognized host)" \
|
||||
"git.mosaicstack.dev" "no-such-agent" \
|
||||
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent.token"
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
# 4b. MOSAIC_GIT_IDENTITY env source (takes priority over git config), same
|
||||
# recognized-host / absent-token scenario -> also fails loud.
|
||||
assert_failloud "fail-loud via MOSAIC_GIT_IDENTITY env (recognized host)" \
|
||||
"git.mosaicstack.dev" "no-such-agent-env" \
|
||||
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent-env.token" \
|
||||
MOSAIC_GIT_IDENTITY=no-such-agent-env
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Correct per-slot token PATH per host: same agent id, only a usc token
|
||||
# exists. usc host returns it (happy path, unchanged). mosaicstack host
|
||||
# has NO per-slot token for this identity -> Patch 2b fail-loud applies
|
||||
# there too (must NOT fall back to the shared mosaicstack token, and must
|
||||
# NOT leak the agent's usc token either).
|
||||
# ---------------------------------------------------------------------------
|
||||
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity agentD
|
||||
out=$(call_get_gitea_token "git.uscllc.com")
|
||||
assert_eq "host-scoped token path (usc)" "agentD-usc-token" "$out"
|
||||
assert_failloud "fail-loud on cross-host absence (no fallback, no cross-host leak)" \
|
||||
"git.mosaicstack.dev" "agentD" \
|
||||
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentD.token"
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Scope containment: identity explicitly requested, but the host is NOT a
|
||||
# recognized Gitea host (no per-slot token scheme at all) -> Patch 2b does
|
||||
# NOT apply; existing fall-through behavior is unchanged (ends in the
|
||||
# pre-existing generic failure since no shared credentials match either,
|
||||
# NOT the fail-loud diagnostic path).
|
||||
# ---------------------------------------------------------------------------
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
|
||||
set +e
|
||||
out=$(call_get_gitea_token "github.com" 2>"$WORK_DIR/stderr-scope.tmp")
|
||||
rc=$?
|
||||
set -e
|
||||
err=$(cat "$WORK_DIR/stderr-scope.tmp")
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL: unrecognized host + identity — expected nonzero (no credentials configured), got 0" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ "$err" == *"no per-slot token at"* ]]; then
|
||||
echo "FAIL: unrecognized host + identity — fail-loud diagnostic must not fire for a host with no per-slot scheme:" >&2
|
||||
echo "$err" >&2
|
||||
fail=1
|
||||
fi
|
||||
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "get_gitea_token identity resolution regression passed"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
||||
#
|
||||
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
||||
# Asserts, per wrapper:
|
||||
# 1. `--help` exits 0 and prints usage.
|
||||
# 2. `-h` exits 0 and prints usage.
|
||||
# 3. A genuine unknown flag still exits nonzero (usage() default path untouched).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
WRAPPERS=(
|
||||
issue-assign.sh
|
||||
issue-create.sh
|
||||
issue-list.sh
|
||||
milestone-create.sh
|
||||
pr-create.sh
|
||||
pr-list.sh
|
||||
pr-merge.sh
|
||||
)
|
||||
|
||||
fail=0
|
||||
|
||||
for wrapper in "${WRAPPERS[@]}"; do
|
||||
path="$SCRIPT_DIR/$wrapper"
|
||||
|
||||
if ! output=$(bash "$path" --help 2>&1); then
|
||||
echo "FAIL: $wrapper --help exited nonzero" >&2
|
||||
fail=1
|
||||
elif [[ "$output" != Usage:* ]]; then
|
||||
echo "FAIL: $wrapper --help did not print usage" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if ! bash "$path" -h >/dev/null 2>&1; then
|
||||
echo "FAIL: $wrapper -h exited nonzero" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if bash "$path" --this-is-not-a-real-flag >/dev/null 2>&1; then
|
||||
echo "FAIL: $wrapper accepted an unknown flag (should have exited nonzero)" >&2
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "help-exit-code regression passed (7/7 wrappers)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -0,0 +1,571 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for issue-comment.sh's Gitea comment write + verification
|
||||
# (#865).
|
||||
#
|
||||
# The #865 defect class: tea 0.11.1's `tea issue comment ...` (a nonexistent
|
||||
# subcommand) silently no-ops yet exits 0, and tea cannot emit the id of a
|
||||
# record it created — so an exit code is worthless as proof of a durable write.
|
||||
# The wrapper therefore does NOT write via tea at all. It POSTs the comment to
|
||||
# the Gitea REST API (which returns the created comment object, including its
|
||||
# id), then GETs THAT EXACT id back and requires it to match on id, author
|
||||
# (acting identity), body, and issue. Because verification is keyed to the id
|
||||
# the create returned, no concurrent comment can masquerade as this write, and a
|
||||
# suppressed/no-op create yields no id and fails closed.
|
||||
#
|
||||
# This harness models a REAL server: the curl stub keeps persistent comment
|
||||
# state on disk, the POST actually CREATES and PERSISTS a record and returns its
|
||||
# id, and the read-back GET reads that same state. There is no independently
|
||||
# fabricated record for the wrapper to "find" — the only way verification
|
||||
# passes is if the POST genuinely created the record the read-back retrieves.
|
||||
# It proves the wrapper:
|
||||
# 1. never shells out to tea to write (no `tea comment` / `tea issue comment`);
|
||||
# 2. creates the comment via REST POST and learns the provider-returned id;
|
||||
# 3. verifies THAT EXACT id by direct GET, attributed to the acting identity;
|
||||
# 4. fails closed when the write is a no-op even though a concurrent
|
||||
# SAME-IDENTITY comment with the same body already exists (the closed
|
||||
# concurrency window — no fallback list scan can rescue a no-op);
|
||||
# 5. fails closed when the created record is not authored by the acting
|
||||
# identity;
|
||||
# 6. treats the exact-id GET as the SOLE authority — it performs NO follow-up
|
||||
# list enumeration (the stub exposes no comment-list endpoint, so any
|
||||
# residual enumeration attempt would fail the run);
|
||||
# 7. with a RESOLVABLE --login override, performs the write, the /user identity
|
||||
# lookup, and the read-back ALL under THAT login's token/identity — never
|
||||
# the host default;
|
||||
# 8. with an UNRESOLVABLE --login override, FAILS CLOSED (nonzero, no write, no
|
||||
# success line) instead of silently downgrading to the host default
|
||||
# identity — the token seam maps each bearer token to the identity it
|
||||
# authenticates as, so a misattributed write is caught;
|
||||
# 9. with a --login override whose tea config URL is a DIFFERENT host than the
|
||||
# repo remote, FAILS CLOSED (host-bound token selection) rather than sending
|
||||
# that other host's credential cross-host;
|
||||
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
|
||||
# success or the failure path — nested function-scoped RETURN traps do not
|
||||
# clobber each other and every scratch file is removed on all exit paths.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-readback}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
XDG_DIR="$WORK_DIR/xdg"
|
||||
TEA_LOG="$WORK_DIR/tea.log"
|
||||
CURL_LOG="$WORK_DIR/curl.log"
|
||||
# Full curl argv per invocation — proves the bearer token never rides in argv.
|
||||
CURL_ARGV_LOG="$WORK_DIR/curl-argv.log"
|
||||
AUTH_LOG="$WORK_DIR/auth.log"
|
||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
STATE_FILE="$WORK_DIR/comments.json"
|
||||
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
|
||||
# check can assert every POST/GET body + metadata temp file is cleaned up.
|
||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
ISSUE_NUMBER=7
|
||||
REPO_SLUG="mosaicstack/stack"
|
||||
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
|
||||
API_ROOT="https://git.mosaicstack.dev/api/v1"
|
||||
BODY='durable "note" -- marker'
|
||||
ACTING_LOGIN="primary-reviewer"
|
||||
FOREIGN_LOGIN="other-writer"
|
||||
# A dedicated per-role --login override identity, with its own token stored in
|
||||
# tea's config (exactly the author-not-equal-reviewer hardening path).
|
||||
OVERRIDE_LOGIN="delegated-reviewer"
|
||||
DEFAULT_TOKEN="test-only-placeholder"
|
||||
OVERRIDE_TOKEN="override-token-placeholder"
|
||||
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
|
||||
# the repo remote (git.mosaicstack.dev). Its token must NEVER be sent to the
|
||||
# repo host: host-bound selection must fail closed on the host mismatch.
|
||||
CROSS_HOST_LOGIN="foreign-host-reviewer"
|
||||
CROSS_HOST_TOKEN="cross-host-token-placeholder"
|
||||
|
||||
# tea config: the override login has its own token here (as tea itself stores
|
||||
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
|
||||
# present, so the no-override default path resolves via the host credential
|
||||
# fallback while an explicit --login must resolve from this file or fail closed.
|
||||
# A second login is configured for a DIFFERENT host to exercise host-bound
|
||||
# rejection.
|
||||
mkdir -p "$XDG_DIR/tea"
|
||||
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
||||
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
handle.write("logins:\n")
|
||||
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
|
||||
handle.write(" url: https://git.mosaicstack.dev\n")
|
||||
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
|
||||
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
|
||||
handle.write(" url: https://git.uscllc.com\n")
|
||||
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
|
||||
PY
|
||||
|
||||
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
||||
json.dump({
|
||||
"gitea": {
|
||||
"mosaicstack": {
|
||||
"url": os.environ["CONFIGURED_GITEA_URL"],
|
||||
"token": "test-only-placeholder",
|
||||
}
|
||||
}
|
||||
}, credentials)
|
||||
PY
|
||||
|
||||
# tea stub: only ever answers the login list (used to resolve the default login
|
||||
# name). It must NEVER be asked to write a comment — the wrapper writes via REST.
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "$ISSUE_COMMENT_TEA_LOG"
|
||||
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Unexpected tea command (wrapper must not write via tea): $*" >&2
|
||||
exit 92
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
# curl stub: a small REST server backed by persistent on-disk comment state.
|
||||
# GET /user -> acting identity
|
||||
# POST /issues/7/comments -> CREATE + PERSIST, return created object
|
||||
# GET /issues/comments/{id} -> read the persisted record by exact id
|
||||
# There is deliberately NO comment-LIST endpoint: exact-id read-back is the sole
|
||||
# authority, so any residual list enumeration attempt hits the unexpected-request
|
||||
# guard and fails the test.
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Record the FULL argv exactly as spawned, before consumption. The bearer token
|
||||
# must NOT appear here — it is delivered via a curl --config file (#865 ITEM 3a),
|
||||
# so only the config file PATH may show up.
|
||||
printf '%s\n' "$*" >> "$ISSUE_COMMENT_CURL_ARGV_LOG"
|
||||
|
||||
output_file=""
|
||||
method="GET"
|
||||
url=""
|
||||
data=""
|
||||
auth_token=""
|
||||
config_file=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o) output_file="$2"; shift 2 ;;
|
||||
-H)
|
||||
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
|
||||
shift 2 ;;
|
||||
-K|--config) config_file="$2"; shift 2 ;;
|
||||
-w) shift 2 ;;
|
||||
-X) method="$2"; shift 2 ;;
|
||||
-d|--data) data="$2"; shift 2 ;;
|
||||
-s|-S|-sS) shift ;;
|
||||
http://*|https://*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve the bearer token from the curl --config file (its real, secure source);
|
||||
# fall back to an -H header only for defense in depth. The config line is
|
||||
# `header = "Authorization: token <value>"`.
|
||||
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
|
||||
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
|
||||
if [[ "$config_hdr" == *"token "* ]]; then
|
||||
auth_token="${config_hdr##*token }"
|
||||
auth_token="${auth_token%\"}"
|
||||
fi
|
||||
fi
|
||||
|
||||
path="${url%%\?*}"
|
||||
query="${url#*\?}"
|
||||
[[ "$query" == "$url" ]] && query=""
|
||||
printf '%s %s\n' "$method" "$url" >> "$ISSUE_COMMENT_CURL_LOG"
|
||||
|
||||
# Map the presented bearer token to the identity it authenticates as — the same
|
||||
# derivation Gitea's own /user does. The wrapper's write, /user lookup, and
|
||||
# read-back must all carry the SAME token, so the acting identity recorded here
|
||||
# reveals which credential actually performed the request.
|
||||
acting_identity=""
|
||||
case "$auth_token" in
|
||||
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
|
||||
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
|
||||
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
|
||||
esac
|
||||
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
|
||||
|
||||
write_response() {
|
||||
local status="$1" body="$2"
|
||||
[[ -n "$output_file" ]] || exit 96
|
||||
printf '%s' "$body" > "$output_file"
|
||||
printf '%s' "$status"
|
||||
}
|
||||
|
||||
if [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_ROOT/user" ]]; then
|
||||
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
|
||||
write_response 200 "$(ISSUE_COMMENT_LOGIN="$acting_identity" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
print(json.dumps({"login": os.environ["ISSUE_COMMENT_LOGIN"]}))
|
||||
PY
|
||||
)"
|
||||
elif [[ "$method" == "POST" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
|
||||
result=$(ISSUE_COMMENT_ACTING_LOGIN="${acting_identity:-$ISSUE_COMMENT_ACTING_LOGIN}" ISSUE_COMMENT_DATA="$data" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
||||
mode = os.environ["ISSUE_COMMENT_TEST_MODE"]
|
||||
acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"]
|
||||
foreign = os.environ["ISSUE_COMMENT_FOREIGN_LOGIN"]
|
||||
repo = os.environ["ISSUE_COMMENT_REPO_SLUG"]
|
||||
body = json.loads(os.environ["ISSUE_COMMENT_DATA"]).get("body")
|
||||
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
comments = json.load(handle)
|
||||
|
||||
# no-op-concurrent: the wrapper's own write is SUPPRESSED (returns 200 with no
|
||||
# created object) even though a concurrent same-identity comment already exists
|
||||
# in state. Nothing is persisted; there is no created id to verify.
|
||||
if mode == "no-op-concurrent":
|
||||
print("200")
|
||||
print(json.dumps({}))
|
||||
raise SystemExit(0)
|
||||
|
||||
author = foreign if mode == "author-mismatch" else acting
|
||||
new_id = (max((c["id"] for c in comments), default=0)) + 1
|
||||
# REAL Gitea comment shape: issue_url is the WEB (html) path, not an API path,
|
||||
# and a plain issue comment leaves pull_request_url empty. The URL-injection
|
||||
# modes persist a record whose id/author/body are all correct but whose
|
||||
# issue_url is forged, so ONLY the origin+path verification can catch them.
|
||||
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
|
||||
if mode == "url-wrong-host":
|
||||
issue_url = f"https://evil.example/{repo}/issues/7"
|
||||
elif mode == "url-wrong-owner":
|
||||
issue_url = "https://git.mosaicstack.dev/attacker/stack/issues/7"
|
||||
elif mode == "url-wrong-repo":
|
||||
issue_url = "https://git.mosaicstack.dev/mosaicstack/other/issues/7"
|
||||
elif mode == "url-suffix-injection":
|
||||
# Prefix-injected: a bare endswith("/<slug>/issues/7") test would ACCEPT this.
|
||||
issue_url = f"https://git.mosaicstack.dev/deceptive/{repo}/issues/7"
|
||||
record = {
|
||||
"id": new_id,
|
||||
"body": body,
|
||||
"user": {"login": author},
|
||||
"issue_url": issue_url,
|
||||
"pull_request_url": "",
|
||||
}
|
||||
comments.append(record)
|
||||
with open(state_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(comments, handle)
|
||||
print("201")
|
||||
print(json.dumps(record))
|
||||
PY
|
||||
)
|
||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
|
||||
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
||||
wanted = int(os.environ["ISSUE_COMMENT_GET_ID"])
|
||||
with open(state_path, encoding="utf-8") as handle:
|
||||
comments = json.load(handle)
|
||||
match = next((c for c in comments if c["id"] == wanted), None)
|
||||
if match is None:
|
||||
print("404")
|
||||
print(json.dumps({"message": "not found"}))
|
||||
else:
|
||||
print("200")
|
||||
print(json.dumps(match))
|
||||
PY
|
||||
)
|
||||
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
|
||||
else
|
||||
echo "Unexpected curl request: $method $url" >&2
|
||||
exit 97
|
||||
fi
|
||||
SH
|
||||
chmod +x "$BIN_DIR/curl"
|
||||
|
||||
# Seed persistent server state for a mode, then run the wrapper against it.
|
||||
seed_state() {
|
||||
local mode="$1"
|
||||
ISSUE_COMMENT_SEED_MODE="$mode" ISSUE_COMMENT_SEED_BODY="$BODY" \
|
||||
ISSUE_COMMENT_SEED_ACTING="$ACTING_LOGIN" ISSUE_COMMENT_SEED_REPO="$REPO_SLUG" \
|
||||
python3 - "$STATE_FILE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
mode = os.environ["ISSUE_COMMENT_SEED_MODE"]
|
||||
body = os.environ["ISSUE_COMMENT_SEED_BODY"]
|
||||
acting = os.environ["ISSUE_COMMENT_SEED_ACTING"]
|
||||
repo = os.environ["ISSUE_COMMENT_SEED_REPO"]
|
||||
# REAL Gitea comment shape: issue_url is the WEB path, pull_request_url empty.
|
||||
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
|
||||
|
||||
|
||||
def comment(cid, text, author):
|
||||
return {
|
||||
"id": cid,
|
||||
"body": text,
|
||||
"user": {"login": author},
|
||||
"issue_url": issue_url,
|
||||
"pull_request_url": "",
|
||||
}
|
||||
|
||||
|
||||
if mode == "fresh-success":
|
||||
# 50 pre-existing comments already exist; the comment this run creates
|
||||
# becomes id 51, proving exact-id read-back works regardless of how many
|
||||
# comments precede it (no list enumeration is involved).
|
||||
comments = [comment(i, f"prior {i}", acting) for i in range(1, 51)]
|
||||
elif mode == "no-op-concurrent":
|
||||
# A concurrent SAME-IDENTITY comment with the IDENTICAL body already exists.
|
||||
# The wrapper's own write will be a no-op; it must still fail closed because
|
||||
# no created id is returned — it must not scan and accept this record.
|
||||
comments = [comment(55, body, acting)]
|
||||
else: # author-mismatch
|
||||
comments = []
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump(comments, handle)
|
||||
PY
|
||||
}
|
||||
|
||||
run_comment() {
|
||||
local mode="$1"
|
||||
shift
|
||||
: > "$TEA_LOG"
|
||||
: > "$CURL_LOG"
|
||||
: > "$CURL_ARGV_LOG"
|
||||
: > "$AUTH_LOG"
|
||||
: > "$OUTPUT_FILE"
|
||||
seed_state "$mode"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
XDG_CONFIG_HOME="$XDG_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
|
||||
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
|
||||
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
|
||||
ISSUE_COMMENT_AUTH_LOG="$AUTH_LOG" \
|
||||
ISSUE_COMMENT_STATE="$STATE_FILE" \
|
||||
ISSUE_COMMENT_TEST_MODE="$mode" \
|
||||
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
|
||||
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
|
||||
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
|
||||
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
|
||||
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
|
||||
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
|
||||
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
|
||||
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
|
||||
ISSUE_COMMENT_API_BASE="$API_BASE" \
|
||||
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
|
||||
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
|
||||
# request bodies + metadata). Called after both success and failure paths so a
|
||||
# clobbered/leaked RETURN trap is caught on every exit route.
|
||||
assert_no_temp_leak() {
|
||||
local context="$1" leaked
|
||||
# Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the
|
||||
# bearer token and must be unlinked on every exit path.
|
||||
leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-issue-comment-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
|
||||
if [[ -n "$leaked" ]]; then
|
||||
echo "FAIL: issue-comment temp files leaked ($context):" >&2
|
||||
printf '%s\n' "$leaked" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Assert the presented bearer token NEVER appeared in curl's argv (it must travel
|
||||
# via a curl --config file), and that --config auth was actually used. On the
|
||||
# expected path grep matches nothing, so no token value is ever printed.
|
||||
assert_token_not_in_argv() {
|
||||
local context="$1"
|
||||
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" "$CURL_ARGV_LOG"; then
|
||||
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then
|
||||
echo "FAIL: curl was not invoked with --config file auth ($context)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
|
||||
# provider-returned id — no list enumeration is involved.
|
||||
run_comment fresh-success
|
||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
|
||||
# The write is a REST POST, never a tea comment.
|
||||
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
|
||||
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
|
||||
echo "FAIL: wrapper wrote a comment via tea instead of REST" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Read-back is a DIRECT GET of the exact created id.
|
||||
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
|
||||
# Acting identity resolved via GET /user.
|
||||
grep -q "^GET $API_ROOT/user$" "$CURL_LOG"
|
||||
# No comment-list enumeration is performed — the exact-id GET is authoritative.
|
||||
if grep -Eq "^GET $API_BASE/issues/7/comments(\?|$)" "$CURL_LOG"; then
|
||||
echo "FAIL: wrapper performed a redundant comment-list enumeration" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Default path (no --login): the host credential fallback resolves, and the
|
||||
# write is performed AND self-verified under the host-default acting identity.
|
||||
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
|
||||
grep -q "^GET $API_BASE/issues/comments/51 $ACTING_LOGIN$" "$AUTH_LOG"
|
||||
# Success path leaves no scratch temp files behind.
|
||||
assert_no_temp_leak "fresh-success"
|
||||
# ITEM 3a: the token drove the write/read-back chain but never appeared in curl
|
||||
# argv — it was passed via a curl --config file.
|
||||
assert_token_not_in_argv "fresh-success default-token"
|
||||
|
||||
# Case 2: a no-op write with a concurrent SAME-IDENTITY, same-body comment
|
||||
# already present must FAIL CLOSED — the closed concurrency window.
|
||||
if run_comment no-op-concurrent; then
|
||||
echo "FAIL: wrapper reported success when its write no-opped but a concurrent same-identity comment existed" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: wrapper accepted a concurrent record for a no-op write (window not closed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# It must NOT have fallen back to a list scan that could find the concurrent id.
|
||||
if grep -q "^GET $API_BASE/issues/comments/55$" "$CURL_LOG"; then
|
||||
echo "FAIL: wrapper read back the concurrent comment id 55 (illegitimate fallback)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Case 3: a created record NOT authored by the acting identity must FAIL CLOSED.
|
||||
if run_comment author-mismatch; then
|
||||
echo "FAIL: wrapper accepted a created comment authored by a different identity" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Failure-after-read-back path must ALSO leave no scratch temp files behind
|
||||
# (proves the RETURN traps clean up on the error-return route, not just success).
|
||||
assert_no_temp_leak "author-mismatch"
|
||||
|
||||
# Case 4: a RESOLVABLE --login override — the write, the /user identity lookup,
|
||||
# and the read-back must ALL be performed under THAT login's token/identity, not
|
||||
# the host default. The override login has id 1 (empty seed).
|
||||
run_comment override-success --login "$OVERRIDE_LOGIN"
|
||||
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 1)' "$OUTPUT_FILE"
|
||||
grep -q "^GET $API_ROOT/user $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
||||
grep -q "^POST $API_BASE/issues/7/comments $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
||||
grep -q "^GET $API_BASE/issues/comments/1 $OVERRIDE_LOGIN$" "$AUTH_LOG"
|
||||
# The host-default identity must NOT have performed ANY request in this run.
|
||||
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||
echo "FAIL: an explicit --login override request was performed under the host default identity" >&2
|
||||
cat "$AUTH_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Case 5: an UNRESOLVABLE --login override (name absent from tea config) must
|
||||
# FAIL CLOSED — no silent downgrade to the host default identity: nonzero exit,
|
||||
# no success line, and NO write performed.
|
||||
if run_comment override-unresolvable --login "nonexistent-typo-login"; then
|
||||
echo "FAIL: unresolvable --login override did not fail closed" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: unresolvable --login override reported success" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
|
||||
echo "FAIL: unresolvable --login override still performed a write" >&2
|
||||
exit 1
|
||||
fi
|
||||
# And it must not have silently fallen back to the host default identity.
|
||||
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||
echo "FAIL: unresolvable --login override fell back to the host default identity" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Case 6: a --login override that IS present in tea config but whose URL is a
|
||||
# DIFFERENT host than the repo remote must FAIL CLOSED (host-bound selection).
|
||||
# The cross-host token must NEVER be sent to the repo host, and no write occurs.
|
||||
if run_comment cross-host --login "$CROSS_HOST_LOGIN"; then
|
||||
echo "FAIL: cross-host --login override did not fail closed" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: cross-host --login override reported success" >&2
|
||||
exit 1
|
||||
fi
|
||||
# The cross-host credential must not have performed ANY request against the repo
|
||||
# host — no request may be attributed to the cross-host identity.
|
||||
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
|
||||
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
|
||||
cat "$AUTH_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
|
||||
echo "FAIL: cross-host --login override still performed a write" >&2
|
||||
exit 1
|
||||
fi
|
||||
# It must not have silently downgraded to the host default identity either.
|
||||
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
|
||||
echo "FAIL: cross-host --login override fell back to the host default identity" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "cross-host"
|
||||
|
||||
# Cases 7-10 (#865 Blocker 3): the created record's id/author/body are all
|
||||
# correct, but its provider-returned issue_url is forged. Verification pins the
|
||||
# URL's ORIGIN (scheme+host+effective-port) and its FULL path (deployment prefix
|
||||
# + exact owner/repo + kind + number), so each forgery must FAIL CLOSED. A bare
|
||||
# endswith/suffix test would wrongly accept the look-alike-host and
|
||||
# prefix-injection variants.
|
||||
for bad_mode in url-wrong-host url-wrong-owner url-wrong-repo url-suffix-injection; do
|
||||
if run_comment "$bad_mode"; then
|
||||
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: forged comment URL ($bad_mode) passed verification" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_no_temp_leak "$bad_mode"
|
||||
done
|
||||
|
||||
# Sanity: the exact same verification path still ACCEPTS a legitimate web-shaped
|
||||
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
|
||||
# is not rejecting genuine writes.
|
||||
|
||||
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|
||||
@@ -55,6 +55,11 @@ JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "api" ]]; then
|
||||
printf '%s\n' '{"login":"ci-bot"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
||||
desc=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #703: interactive issue creation and stale Tea-user fallback.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
LOG_FILE="$WORK_DIR/calls.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
||||
JSON
|
||||
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${1:-}" == "api" ]]; then
|
||||
if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then
|
||||
echo 'GetUserByName: stale configured user' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' '{"login":"current-user"}'
|
||||
exit 0
|
||||
fi
|
||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
printf '%s\n' '{"number":703}'
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||
|
||||
run_wrapper() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
)
|
||||
}
|
||||
|
||||
: > "$LOG_FILE"
|
||||
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
||||
|
||||
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE"
|
||||
|
||||
# Explicit values take precedence in interactive mode: no title input is
|
||||
# supplied, but the wrapper still creates the issue with the explicit title.
|
||||
: > "$LOG_FILE"
|
||||
printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null
|
||||
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE"
|
||||
|
||||
: > "$LOG_FILE"
|
||||
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr"
|
||||
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE"
|
||||
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr"
|
||||
if grep -q -- 'tea issue create' "$LOG_FILE"; then
|
||||
echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
: > "$LOG_FILE"
|
||||
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr"
|
||||
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE"
|
||||
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr"
|
||||
if grep -q -- 'tea pr create' "$LOG_FILE"; then
|
||||
echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo 'issue-create interactive/auth regression harness passed'
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-mutate-push-guard.sh -- needles for the mutation generator.
|
||||
#
|
||||
# The generator's entire output is a COVERAGE CLAIM, and the README publishes it.
|
||||
# That makes it the most dangerous file here: when it is wrong it does not fail,
|
||||
# it reassures. Two of its three defects were found by review rather than by any
|
||||
# test, so these are the cases that had to exist.
|
||||
#
|
||||
# g1 a RED BASELINE must be refused before any mutant runs. Previously ONE
|
||||
# pre-existing suite failure -- changing no guard behaviour at all --
|
||||
# satisfied every mutant: 13 killed, 0 survived, table emitted, exit 0.
|
||||
# g3 an INTERRUPTED run must leave the subject byte-identical. Restoration
|
||||
# used to lean on an EXIT trap, and A TRAP IS CLEANUP, NOT ISOLATION:
|
||||
# SIGKILL cannot run it, so an interrupted run stranded a mutated
|
||||
# push-guard.sh that the next suite run silently inherited.
|
||||
# g2 is the positive control. Without it every case here could be passing
|
||||
# because the generator refuses unconditionally, which is a wall, not a gate.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GEN="$HERE/mutate-push-guard.sh"
|
||||
PASS=0; FAIL=0
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
ok() { printf ' ok [%-16s] %s\n' "$1" "${2:-}"; PASS=$(( PASS + 1 )); }
|
||||
bad() { printf ' FAIL [%-16s] %s\n' "$1" "$2"; FAIL=$(( FAIL + 1 )); }
|
||||
|
||||
# fixture <name> -- a directory holding an independent copy of guard + suite
|
||||
fixture() {
|
||||
local d="$TMP/$1"; mkdir -p "$d"
|
||||
install -m 755 "$HERE/push-guard.sh" "$d/push-guard.sh"
|
||||
install -m 755 "$HERE/test-push-guard.sh" "$d/test-push-guard.sh"
|
||||
printf '%s\n' "$d"
|
||||
}
|
||||
|
||||
echo "== g1: a RED BASELINE must be refused, with NO table emitted =="
|
||||
# The injected case asserts exit 99 from `true`. It fails always, and it changes
|
||||
# NO guard behaviour -- which is the whole point: a defect anywhere in the suite
|
||||
# used to be enough to certify every branch as covered.
|
||||
g1="$(fixture red)"
|
||||
python3 - "$g1/test-push-guard.sh" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1]; s = open(p).read()
|
||||
anchor = 'mkdir -p "$WORK_DIR"\n'
|
||||
assert s.count(anchor) == 1, "injection anchor not unique -- fixture would not be the red baseline"
|
||||
s = s.replace(anchor, anchor + '\nexpect NEEDLE 99 "injected always-failing case" -- true\n', 1)
|
||||
open(p, "w").write(s)
|
||||
PY
|
||||
# Prove the fixture really IS red before asserting the generator notices, or g1
|
||||
# could pass against a green suite and test nothing.
|
||||
if "$g1/test-push-guard.sh" >/dev/null 2>&1; then
|
||||
bad g1-fixture "injected suite still passes -- fixture is not a red baseline"
|
||||
else
|
||||
ok g1-fixture "fixture suite is red, as required"
|
||||
fi
|
||||
out="$("$GEN" --dir "$g1" 2>&1)"; rc=$?
|
||||
if (( rc != 0 )) && [[ "$out" == *"REFUSING: baseline is not green"* ]]; then
|
||||
ok g1-refused "exit $rc, refused before mutating"
|
||||
else
|
||||
bad g1-refused "wanted nonzero + refusal; got rc=$rc"
|
||||
fi
|
||||
if [[ "$out" != *"README TABLE"* && "$out" != *"killed,"* ]]; then
|
||||
ok g1-no-table "no coverage table emitted from a red baseline"
|
||||
else
|
||||
bad g1-no-table "a table or kill count was published despite the red baseline"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== g3: an INTERRUPTED run must not alter the subject =="
|
||||
# THE KILL MUST LAND INSIDE A MUTATION WINDOW OR THIS CASE PROVES NOTHING.
|
||||
# First attempt used `timeout -s KILL 3`. At three seconds the generator is still
|
||||
# running its BASELINE, so no mutation has been applied yet and the subject is
|
||||
# trivially unchanged -- for the ORIGINAL in-place generator too, which I
|
||||
# confirmed by running it. The control passed for a reason unrelated to the fix:
|
||||
# a vacuous control, in the control written for blocker 3.
|
||||
#
|
||||
# So the kill is now driven from INSIDE the run. The fixture's suite counts its
|
||||
# own invocations and SIGKILLs the generator on the second one -- invocation 1 is
|
||||
# the baseline, invocation 2 happens with mutant #1 APPLIED. That is exactly the
|
||||
# window where an in-place generator strands a mutated subject.
|
||||
instrument_kill_at_second_run() {
|
||||
python3 - "$1" <<'PY'
|
||||
import sys
|
||||
p = sys.argv[1]; s = open(p).read()
|
||||
anchor = 'PASS=0\nFAIL=0\n'
|
||||
assert s.count(anchor) == 1, "instrumentation anchor not unique"
|
||||
inject = anchor + '''
|
||||
if [[ -n "${G3_COUNTER:-}" ]]; then
|
||||
n=$(( $(cat "$G3_COUNTER" 2>/dev/null || echo 0) + 1 ))
|
||||
printf '%s' "$n" > "$G3_COUNTER"
|
||||
# Invocation 2 = first mutant applied. Kill the generator where it hurts.
|
||||
# `kill -9 0` targets the whole PROCESS GROUP, not $PPID: the generator runs
|
||||
# the suite inside $( ), which forks, so $PPID is that subshell and killing
|
||||
# it merely ends the command substitution -- the generator carries on and
|
||||
# exits 0. The caller puts the generator in its OWN group via setsid, so the
|
||||
# group is exactly the generator and its children, and this harness is not
|
||||
# in it.
|
||||
(( n == 2 )) && kill -9 0
|
||||
fi
|
||||
'''
|
||||
open(p, "w").write(s.replace(anchor, inject, 1))
|
||||
PY
|
||||
}
|
||||
|
||||
# run_killed <dir> -> echoes "<rc> <before> <after>"
|
||||
run_killed() {
|
||||
local d="$1" gen="$2" before after rc
|
||||
instrument_kill_at_second_run "$d/test-push-guard.sh"
|
||||
before="$(sha256sum "$d/push-guard.sh" | cut -d' ' -f1)"
|
||||
G3_COUNTER="$d/.count" setsid --wait "$gen" --dir "$d" >/dev/null 2>&1; rc=$?
|
||||
after="$(sha256sum "$d/push-guard.sh" | cut -d' ' -f1)"
|
||||
printf '%s %s %s\n' "$rc" "$before" "$after"
|
||||
}
|
||||
|
||||
g3="$(fixture killed)"
|
||||
read -r krc before after <<<"$(run_killed "$g3" "$GEN")"
|
||||
if (( krc != 0 )); then
|
||||
ok g3-was-killed "generator died mid-mutation (exit $krc)"
|
||||
else
|
||||
bad g3-was-killed "generator exited 0 -- the kill never landed, so the check below is vacuous"
|
||||
fi
|
||||
if [[ "$before" == "$after" ]]; then
|
||||
ok g3-subject-intact "push-guard.sh byte-identical after the kill"
|
||||
else
|
||||
bad g3-subject-intact "SUBJECT MUTATED AND STRANDED: $before -> $after"
|
||||
fi
|
||||
|
||||
# PROVE THE NEEDLE BITES. Reconstruct the pre-fix mechanism -- mutate the source
|
||||
# in place, restore from an EXIT trap -- and put it through the identical kill.
|
||||
# If this does NOT strand a mutated file, g3-subject-intact is measuring nothing
|
||||
# and the isolation fix is unevidenced.
|
||||
g3o="$(fixture killed-inplace)"
|
||||
python3 - "$GEN" "$g3o/gen-inplace.sh" <<'PY'
|
||||
import sys
|
||||
s = open(sys.argv[1]).read()
|
||||
old = '''install -m 755 "$SRC_TARGET" "$WORK/push-guard.sh"
|
||||
install -m 755 "$SRC_SUITE" "$WORK/test-push-guard.sh"
|
||||
TARGET="$WORK/push-guard.sh"
|
||||
SUITE="$WORK/test-push-guard.sh"
|
||||
BAK="$WORK/push-guard.sh.orig"
|
||||
cp "$TARGET" "$BAK"'''
|
||||
new = '''TARGET="$SRC_TARGET"
|
||||
SUITE="$SRC_SUITE"
|
||||
BAK="$WORK/push-guard.sh.orig"
|
||||
cp "$TARGET" "$BAK"
|
||||
trap 'cp "$BAK" "$TARGET"; rm -rf "$WORK"' EXIT'''
|
||||
assert s.count(old) == 1, "cannot reconstruct the in-place mechanism -- bite proof would be fake"
|
||||
open(sys.argv[2], "w").write(s.replace(old, new, 1))
|
||||
PY
|
||||
chmod +x "$g3o/gen-inplace.sh"
|
||||
read -r _orc obefore oafter <<<"$(run_killed "$g3o" "$g3o/gen-inplace.sh")"
|
||||
if [[ "$obefore" != "$oafter" ]]; then
|
||||
ok g3-needle-bites "in-place generator strands a mutated subject, as it must"
|
||||
else
|
||||
bad g3-needle-bites "the OLD mechanism also left the subject intact -- g3 is vacuous"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== g2: POSITIVE CONTROL -- a clean subject must produce a full green run =="
|
||||
g2="$(fixture clean)"
|
||||
out2="$("$GEN" --dir "$g2" 2>&1)"; rc2=$?
|
||||
if (( rc2 == 0 )) && [[ "$out2" == *"0 survived"* ]]; then
|
||||
ok g2-control "$(printf '%s' "$out2" | grep -E '^[0-9]+ killed')"
|
||||
else
|
||||
bad g2-control "wanted exit 0 with 0 survived; got rc=$rc2"
|
||||
fi
|
||||
# A kill must be attributed to a NAMED case, not to a bare tally -- that is the
|
||||
# fix for blocker 2 and it needs its own assertion.
|
||||
if [[ "$out2" == *" by: "* ]]; then
|
||||
ok g2-attributed "kills name the case they broke"
|
||||
else
|
||||
bad g2-attributed "no per-mutant case attribution in the output"
|
||||
fi
|
||||
|
||||
echo
|
||||
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
(( FAIL == 0 ))
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
||||
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -79,15 +79,24 @@ 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"},"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","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"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
|
||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
||||
echo "unexpected merge payload: $post_data" >&2
|
||||
exit 96
|
||||
fi
|
||||
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
|
||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||
exit 0
|
||||
fi
|
||||
@@ -107,8 +116,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 --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
||||
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
|
||||
echo "--- output ---" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
echo "--- mock log ---" >&2
|
||||
@@ -127,38 +136,6 @@ 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
|
||||
@@ -177,8 +154,8 @@ EOF
|
||||
chmod +x "$MOCK_BIN/tea"
|
||||
unset GITEA_LOGIN
|
||||
: > "$LOG_FILE"
|
||||
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
|
||||
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
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||
exit 1
|
||||
@@ -215,7 +192,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 --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$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
|
||||
@@ -240,7 +217,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 --skip-queue-guard > "$OUTPUT" 2>&1; then
|
||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$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
|
||||
@@ -260,4 +237,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-merge.sh Gitea fallback regression passed"
|
||||
echo "pr-merge.sh Gitea exact-head API regression passed"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/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)"
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/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"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for pr-review.sh's -r/--repo and -H/--host overrides,
|
||||
# the -r repo-exists preflight, and the mosaic-pr-review User-Agent header
|
||||
# (patch family 5/5c, part of #891).
|
||||
#
|
||||
# -r/--repo and -H/--host skip git-remote slug/host inference entirely, for
|
||||
# reviewer worktrees whose origin is nonstandard, wrong, or missing (mirrors
|
||||
# the -r convention of the sibling wrappers pr-view.sh/pr-diff.sh/pr-ci-wait.sh,
|
||||
# #867). When -r is given, the resolved repo is preflighted (GET
|
||||
# .../repos/<slug>) BEFORE any write, so a wrong-host cross-wire surfaces as a
|
||||
# clear preflight error instead of an opaque write-404. Every Gitea curl call
|
||||
# (preflight, write, read-back, /user, PR-head) must carry a
|
||||
# `User-Agent: mosaic-pr-review` header, since some Cloudflare-fronted Gitea
|
||||
# hosts intermittently reject curl's default User-Agent.
|
||||
#
|
||||
# Round 2 (post-review): the approve/request-changes/comment dispatch bodies
|
||||
# each independently called a BARE `host=$(get_remote_host)` (only for a
|
||||
# best-effort tea-login guess) that ignored -H/--host entirely and, under
|
||||
# `set -e`, DIED SILENTLY (exit 1, zero stdout/stderr) whenever no git origin
|
||||
# existed — exactly the case -r/-H exist to support. The "no git origin at
|
||||
# all" fixture below must be a directory TRULY outside any .git ancestry (a
|
||||
# subdirectory with no .git of its own still resolves `git remote get-url
|
||||
# origin` UPWARD to an enclosing repo's origin — a real flaw in the first
|
||||
# version of this fixture, since the test tree itself sits inside the
|
||||
# mosaicstack/stack checkout). It is created via `mktemp -d` under the SYSTEM
|
||||
# /tmp and additionally bounded with GIT_CEILING_DIRECTORIES, so `git rev-parse
|
||||
# --show-toplevel`/`git remote get-url origin` cannot walk past it under any
|
||||
# circumstance.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-repo-host-override}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
STATE_DIR="$WORK_DIR/state"
|
||||
CURL_LOG="$WORK_DIR/curl.log"
|
||||
UA_LOG="$WORK_DIR/ua.log"
|
||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||
TMP_SCRATCH="$WORK_DIR/scratch"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
# A TRUE no-git-origin fixture: created under the system /tmp (NOT under
|
||||
# $WORK_DIR, which sits inside this very checkout's .git ancestry), so that
|
||||
# `git remote get-url origin` run from inside it cannot resolve upward to the
|
||||
# mosaicstack/stack checkout's own real origin.
|
||||
NO_GIT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pr-review-no-origin.XXXXXX")"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR" "$NO_GIT_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
HOST_OVERRIDE_VAL="pr-review-override.test"
|
||||
REPO_OVERRIDE_VAL="acme/widgets"
|
||||
TOKEN_VAL="override-token-xyz"
|
||||
ACTING_LOGIN="override-bot"
|
||||
HEAD_SHA_VAL="feedfacecafebabe0000000000000000000000"
|
||||
|
||||
mkdir -p "$BIN_DIR" "$STATE_DIR" "$TMP_SCRATCH"
|
||||
|
||||
# tea stub: deterministic tea-absent behavior regardless of whether the host
|
||||
# actually has a real `tea` binary on PATH. -r/--repo + -H/--host must not
|
||||
# NEED a resolvable tea login to work — the wrapper falls back to the
|
||||
# host-scoped GITEA_TOKEN credential when no tea login resolves.
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exit 1
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
# curl stub: models the target Gitea instance for the OVERRIDDEN host/repo
|
||||
# only. Any request to a DIFFERENT host/repo (i.e. one derived from git-remote
|
||||
# inference instead of the override) is unexpected and fails the run.
|
||||
cat > "$BIN_DIR/curl" <<SH
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
output_file=""
|
||||
method="GET"
|
||||
payload=""
|
||||
url=""
|
||||
config_file=""
|
||||
ua_seen="0"
|
||||
while [[ \$# -gt 0 ]]; do
|
||||
case "\$1" in
|
||||
-o) output_file="\$2"; shift 2 ;;
|
||||
-H)
|
||||
[[ "\$2" == "User-Agent: mosaic-pr-review" ]] && ua_seen="1"
|
||||
shift 2 ;;
|
||||
-K|--config) config_file="\$2"; shift 2 ;;
|
||||
-w) shift 2 ;;
|
||||
-X) shift 2 ;;
|
||||
-d|--data) payload="\$2"; shift 2 ;;
|
||||
-s|-S|-sS) shift ;;
|
||||
http://*|https://*) url="\$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
printf '%s %s\n' "\$method" "\$url" >> "$CURL_LOG"
|
||||
printf '%s %s\n' "\$ua_seen" "\$url" >> "$UA_LOG"
|
||||
|
||||
write_response() {
|
||||
local status="\$1" body="\$2"
|
||||
[[ -n "\$output_file" ]] || exit 96
|
||||
printf '%s' "\$body" > "\$output_file"
|
||||
printf '%s' "\$status"
|
||||
}
|
||||
|
||||
mode="\$(cat "$STATE_DIR/mode" 2>/dev/null || true)"
|
||||
web_base="https://$HOST_OVERRIDE_VAL"
|
||||
repo="$REPO_OVERRIDE_VAL"
|
||||
|
||||
case "\$url" in
|
||||
"https://$HOST_OVERRIDE_VAL/api/v1/repos/$REPO_OVERRIDE_VAL")
|
||||
if [[ "\$mode" == "repo-missing" ]]; then
|
||||
write_response 404 '{"message":"not found"}'
|
||||
else
|
||||
write_response 200 "{\\"full_name\\":\\"$REPO_OVERRIDE_VAL\\"}"
|
||||
fi
|
||||
;;
|
||||
"https://$HOST_OVERRIDE_VAL/api/v1/user")
|
||||
write_response 200 "{\\"login\\":\\"$ACTING_LOGIN\\"}"
|
||||
;;
|
||||
"https://$HOST_OVERRIDE_VAL/api/v1/repos/$REPO_OVERRIDE_VAL/pulls/123")
|
||||
write_response 200 "{\\"head\\":{\\"sha\\":\\"$HEAD_SHA_VAL\\"}}"
|
||||
;;
|
||||
"https://$HOST_OVERRIDE_VAL/api/v1/repos/$REPO_OVERRIDE_VAL/pulls/123/reviews")
|
||||
PR_REVIEW_PAYLOAD="\$payload" PR_REVIEW_ACTING="$ACTING_LOGIN" \
|
||||
python3 -c '
|
||||
import json, os
|
||||
p = json.loads(os.environ["PR_REVIEW_PAYLOAD"])
|
||||
record = {
|
||||
"id": 501,
|
||||
"state": p.get("event"),
|
||||
"commit_id": p.get("commit_id"),
|
||||
"body": p.get("body"),
|
||||
"user": {"login": os.environ["PR_REVIEW_ACTING"]},
|
||||
}
|
||||
with open("$STATE_DIR/review.json", "w", encoding="utf-8") as fh:
|
||||
json.dump(record, fh)
|
||||
'
|
||||
write_response 201 "\$(cat "$STATE_DIR/review.json")"
|
||||
;;
|
||||
"https://$HOST_OVERRIDE_VAL/api/v1/repos/$REPO_OVERRIDE_VAL/pulls/123/reviews/501")
|
||||
write_response 200 "\$(cat "$STATE_DIR/review.json")"
|
||||
;;
|
||||
"https://$HOST_OVERRIDE_VAL/api/v1/repos/$REPO_OVERRIDE_VAL/issues/123/comments")
|
||||
write_response 201 "{\\"id\\":456,\\"body\\":\\"durable-body\\",\\"user\\":{\\"login\\":\\"$ACTING_LOGIN\\"},\\"issue_url\\":\\"\\",\\"pull_request_url\\":\\"\${web_base}/\${repo}/pulls/123\\"}"
|
||||
;;
|
||||
"https://$HOST_OVERRIDE_VAL/api/v1/repos/$REPO_OVERRIDE_VAL/issues/comments/456")
|
||||
write_response 200 "{\\"id\\":456,\\"body\\":\\"durable-body\\",\\"user\\":{\\"login\\":\\"$ACTING_LOGIN\\"},\\"issue_url\\":\\"\\",\\"pull_request_url\\":\\"\${web_base}/\${repo}/pulls/123\\"}"
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected curl request (host/repo not from the override — inference leaked through?): \$method \$url" >&2
|
||||
exit 97
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$BIN_DIR/curl"
|
||||
|
||||
run() {
|
||||
local repo_dir="$1"; shift
|
||||
: > "$CURL_LOG"
|
||||
: > "$UA_LOG"
|
||||
: > "$OUTPUT_FILE"
|
||||
(
|
||||
cd "$repo_dir"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
TMPDIR="$TMP_SCRATCH" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
GITEA_TOKEN="$TOKEN_VAL" \
|
||||
GITEA_URL="https://$HOST_OVERRIDE_VAL" \
|
||||
GIT_CEILING_DIRECTORIES="$(dirname "$NO_GIT_DIR")" \
|
||||
"$SCRIPT_DIR/pr-review.sh" "$@"
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
set_mode() {
|
||||
printf '%s' "$1" > "$STATE_DIR/mode"
|
||||
}
|
||||
|
||||
# A run must not silently die: exit 1 with a COMPLETELY EMPTY combined
|
||||
# stdout+stderr is precisely the pre-fix signature of the dispatch-level bare
|
||||
# `host=$(get_remote_host)` call dying under `set -e` when there was no git
|
||||
# origin at all. Whatever the outcome, the wrapper must always emit SOME
|
||||
# diagnostic (success line, or an "Error: ..." line).
|
||||
assert_not_silently_dead() {
|
||||
local context="$1" exit_code="$2"
|
||||
if [[ "$exit_code" -ne 0 && ! -s "$OUTPUT_FILE" ]]; then
|
||||
echo "FAIL: pr-review.sh died SILENTLY (exit $exit_code, zero output) in a true no-origin dir ($context)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Sanity-check the fixture itself BEFORE relying on it: from inside NO_GIT_DIR,
|
||||
# `git remote get-url origin` must fail outright — proving this directory is
|
||||
# truly outside any .git ancestry (the flaw this harness had before: a bare
|
||||
# subdirectory of the checkout resolves `origin` UPWARD to the checkout's own
|
||||
# real remote instead of failing).
|
||||
if (cd "$NO_GIT_DIR" && GIT_CEILING_DIRECTORIES="$(dirname "$NO_GIT_DIR")" git remote get-url origin) 2>/dev/null; then
|
||||
echo "FAIL: NO_GIT_DIR fixture is not actually outside any git ancestry — 'git remote get-url origin' resolved upward" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Case 1: -r/--repo and -H/--host are recognized flags (regression lock on
|
||||
# the previous "Unknown option: -r" / "Unknown option: -H" failure). Use a
|
||||
# bogus ACTION so the run fails cheaply, WITHOUT any network I/O, at the
|
||||
# platform-dispatch unknown-action branch rather than at argument parsing —
|
||||
# proving both flags were consumed as options, not rejected.
|
||||
set_mode "repo-ok"
|
||||
if run "$NO_GIT_DIR" -n 123 -a bogus-action -r "$REPO_OVERRIDE_VAL" -H "$HOST_OVERRIDE_VAL"; then
|
||||
echo "FAIL: bogus action unexpectedly succeeded" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Unknown option' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: -r/--repo or -H/--host was not recognized as a valid flag" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Unknown action: bogus-action' "$OUTPUT_FILE"
|
||||
|
||||
# --- Case 2: -h/--help documents both overrides.
|
||||
HELP_TEXT="$("$SCRIPT_DIR/pr-review.sh" -h)"
|
||||
echo "$HELP_TEXT" | grep -q -- '-r, --repo'
|
||||
echo "$HELP_TEXT" | grep -q -- '-H, --host'
|
||||
|
||||
# --- Case 3 (comment): a TRUE no-git-origin dir + -r/-H must not silently die
|
||||
# and must not fail with "not a git repository or no origin remote" either.
|
||||
set_mode "repo-ok"
|
||||
comment_rc=0
|
||||
run "$NO_GIT_DIR" -n 123 -a comment -c durable-body -r "$REPO_OVERRIDE_VAL" -H "$HOST_OVERRIDE_VAL" || comment_rc=$?
|
||||
assert_not_silently_dead "comment" "$comment_rc"
|
||||
if grep -qi 'not a git repository or no origin remote' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: -r/--repo did not tolerate a missing git origin" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
|
||||
# --- Case 3b (approve): same true no-origin dir, the `approve` dispatch body
|
||||
# (its OWN, previously-unguarded `host=$(get_remote_host)` call) must not
|
||||
# silently die either.
|
||||
set_mode "repo-ok"
|
||||
approve_rc=0
|
||||
run "$NO_GIT_DIR" -n 123 -a approve -r "$REPO_OVERRIDE_VAL" -H "$HOST_OVERRIDE_VAL" || approve_rc=$?
|
||||
assert_not_silently_dead "approve" "$approve_rc"
|
||||
if grep -qi 'not a git repository or no origin remote' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: approve's -r/--repo did not tolerate a missing git origin" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Approved and verified Gitea PR #123 (review ID 501)' "$OUTPUT_FILE"
|
||||
|
||||
# --- Case 3c (request-changes): same true no-origin dir, the
|
||||
# `request-changes` dispatch body's OWN previously-unguarded
|
||||
# `host=$(get_remote_host)` call must not silently die either.
|
||||
set_mode "repo-ok"
|
||||
request_changes_rc=0
|
||||
run "$NO_GIT_DIR" -n 123 -a request-changes -c please-fix -r "$REPO_OVERRIDE_VAL" -H "$HOST_OVERRIDE_VAL" || request_changes_rc=$?
|
||||
assert_not_silently_dead "request-changes" "$request_changes_rc"
|
||||
if grep -qi 'not a git repository or no origin remote' "$OUTPUT_FILE"; then
|
||||
echo "FAIL: request-changes's -r/--repo did not tolerate a missing git origin" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'Requested changes and verified on Gitea PR #123 (review ID 501)' "$OUTPUT_FILE"
|
||||
|
||||
# --- Case 4: -r/--repo and -H/--host WIN over git-remote inference, not just
|
||||
# tolerate its absence — set up a real git repo whose origin points at a
|
||||
# COMPLETELY DIFFERENT host/repo and confirm the override, not the remote, is
|
||||
# what gets used (the curl stub only answers for the override host/repo; any
|
||||
# request derived from the wrong remote is an unexpected request and fails
|
||||
# the stub with exit 97).
|
||||
WRONG_REMOTE_DIR="$WORK_DIR/wrong-remote-repo"
|
||||
mkdir -p "$WRONG_REMOTE_DIR"
|
||||
git -C "$WRONG_REMOTE_DIR" init -q
|
||||
git -C "$WRONG_REMOTE_DIR" remote add origin https://git.wrong-inferred-host.example/wrong/repo.git
|
||||
set_mode "repo-ok"
|
||||
run "$WRONG_REMOTE_DIR" -n 123 -a comment -c durable-body -r "$REPO_OVERRIDE_VAL" -H "$HOST_OVERRIDE_VAL"
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
if grep -q 'wrong-inferred-host' "$CURL_LOG"; then
|
||||
echo "FAIL: -r/--repo + -H/--host did not override git-remote inference" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Case 5 (5c): the repo-exists preflight runs BEFORE any write and fails
|
||||
# closed with a clear diagnostic when the resolved repo is unreachable at the
|
||||
# resolved host — proving a wrong-host cross-wire surfaces here, not as an
|
||||
# opaque write-404.
|
||||
set_mode "repo-missing"
|
||||
if run "$NO_GIT_DIR" -n 123 -a comment -c durable-body -r "$REPO_OVERRIDE_VAL" -H "$HOST_OVERRIDE_VAL"; then
|
||||
echo "FAIL: preflight did not fail closed on a missing repo" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q "repo '$REPO_OVERRIDE_VAL' not reachable" "$OUTPUT_FILE"
|
||||
if grep -q '/issues/123/comments' "$CURL_LOG"; then
|
||||
echo "FAIL: wrapper posted a comment despite a failed repo preflight" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Case 6 (5): every Gitea curl call (preflight, /user, comment write,
|
||||
# comment read-back) carries the User-Agent: mosaic-pr-review header.
|
||||
set_mode "repo-ok"
|
||||
run "$NO_GIT_DIR" -n 123 -a comment -c durable-body -r "$REPO_OVERRIDE_VAL" -H "$HOST_OVERRIDE_VAL"
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
call_count="$(wc -l < "$CURL_LOG" | tr -d ' ')"
|
||||
ua_count="$(awk '$1 == "1"' "$UA_LOG" | wc -l | tr -d ' ')"
|
||||
if [[ "$call_count" -lt 4 ]]; then
|
||||
echo "FAIL: expected at least 4 curl calls (preflight, /user, write, read-back), got $call_count" >&2
|
||||
cat "$CURL_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ua_count" != "$call_count" ]]; then
|
||||
echo "FAIL: not every curl call carried User-Agent: mosaic-pr-review ($ua_count/$call_count)" >&2
|
||||
cat "$UA_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-review.sh -r/--repo + -H/--host override + preflight + User-Agent regression passed"
|
||||
+659
@@ -0,0 +1,659 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-push-guard.sh - Needle harness for push-guard.sh.
|
||||
#
|
||||
# Every check gets BOTH polarities:
|
||||
# NEEDLE a deliberately-broken fixture that MUST trip the guard.
|
||||
# CONTROL a clean fixture that MUST pass.
|
||||
#
|
||||
# The controls are not decoration. A guard that failed unconditionally would
|
||||
# satisfy every needle and look fully covered — which is the same class of defect
|
||||
# (an assertion satisfied by the null case) that push-guard.sh exists to prevent.
|
||||
# A needle set without controls cannot tell "working guard" from "broken guard".
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GUARD="$SCRIPT_DIR/push-guard.sh"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$SCRIPT_DIR/.work}"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
# Fresh repo + bare "remote" for each case, so no case can inherit another's state.
|
||||
new_repo() {
|
||||
local name="$1"
|
||||
local dir="$WORK_DIR/$name"
|
||||
rm -rf "$dir"
|
||||
mkdir -p "$dir"
|
||||
git init -q -b main "$dir/repo"
|
||||
git init -q --bare "$dir/remote.git"
|
||||
git -C "$dir/repo" remote add origin "$dir/remote.git"
|
||||
git -C "$dir/repo" config user.name "Needle Harness"
|
||||
git -C "$dir/repo" config user.email "[email protected]"
|
||||
printf 'seed\n' > "$dir/repo/seed.txt"
|
||||
git -C "$dir/repo" add seed.txt
|
||||
git -C "$dir/repo" commit -q -m "seed"
|
||||
printf '%s' "$dir/repo"
|
||||
}
|
||||
|
||||
# write_config <repo> <json-text>
|
||||
#
|
||||
# THIS HELPER USED TO WRITE THE CONFIG UNTRACKED, and the comment that lived here
|
||||
# justified it ("does not need to be staged"). That made every opt-out control in
|
||||
# this file assert the FORBIDDEN provenance and pass — a control that does not
|
||||
# merely test nothing, but tests the OPPOSITE of the requirement and goes green.
|
||||
# The whole design is: ON from anywhere, OFF only from a committed reviewable
|
||||
# artifact. A harness that opts out from an untracked file was proving the bypass
|
||||
# worked. It now COMMITS, so the opt-out controls exercise the supported path.
|
||||
write_config() {
|
||||
printf '%s\n' "$2" > "$1/.push-guard.json"
|
||||
git -C "$1" add .push-guard.json
|
||||
git -C "$1" commit -q -m "push-guard config"
|
||||
}
|
||||
|
||||
# write_config_untracked <repo> <json-text>
|
||||
# Deliberately NOT committed — used only where the untracked config is the thing
|
||||
# under test and the expected outcome is a REFUSAL.
|
||||
write_config_untracked() {
|
||||
printf '%s\n' "$2" > "$1/.push-guard.json"
|
||||
}
|
||||
|
||||
# expect <kind> <expected_exit> <description> [--out <substring>] -- <command...>
|
||||
#
|
||||
# --out asserts a substring of the guard's own output. It exists because exit 0
|
||||
# alone cannot distinguish "checked the files and they were fine" from "matched
|
||||
# no files and had nothing to check". Two controls in an earlier revision of this
|
||||
# harness were passing vacuously for exactly that reason — the pathspec silently
|
||||
# matched nothing. A control that cannot fail is not a control.
|
||||
expect() {
|
||||
local kind="$1" want="$2" desc="$3"; shift 3
|
||||
local need_out=""
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--out) need_out="$2"; shift 2 ;;
|
||||
--) shift; break ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
local got=0 out
|
||||
out="$("$@" 2>&1)" || got=$?
|
||||
|
||||
local why=""
|
||||
[[ "$got" == "$want" ]] || why="wanted exit $want, got $got"
|
||||
if [[ -z "$why" && -n "$need_out" && "$out" != *"$need_out"* ]]; then
|
||||
why="exit $got as expected, but output never said: $need_out"
|
||||
fi
|
||||
|
||||
if [[ -z "$why" ]]; then
|
||||
printf ' PASS [%-7s] %s (exit %s)\n' "$kind" "$desc" "$got"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf ' FAIL [%-7s] %s — %s\n' "$kind" "$desc" "$why"
|
||||
printf '%s\n' "$out" | sed 's/^/ | /'
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
echo "=== (a) conflict markers / unmerged index ==="
|
||||
|
||||
# NEEDLE: staged content carrying real conflict markers.
|
||||
R="$(new_repo a1)"
|
||||
cat > "$R/conflicted.txt" <<'EOF'
|
||||
intro line
|
||||
<<<<<<< HEAD
|
||||
ours
|
||||
=======
|
||||
theirs
|
||||
>>>>>>> feature/x
|
||||
tail line
|
||||
EOF
|
||||
git -C "$R" add conflicted.txt
|
||||
expect NEEDLE 2 "staged conflict markers are refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: a genuine unmerged index (merge in progress).
|
||||
R="$(new_repo a2)"
|
||||
git -C "$R" checkout -q -b other
|
||||
printf 'from-other\n' > "$R/clash.txt"
|
||||
git -C "$R" add clash.txt && git -C "$R" commit -q -m other
|
||||
git -C "$R" checkout -q main
|
||||
printf 'from-main\n' > "$R/clash.txt"
|
||||
git -C "$R" add clash.txt && git -C "$R" commit -q -m main
|
||||
git -C "$R" merge other -q >/dev/null 2>&1 || true
|
||||
expect NEEDLE 2 "unmerged index paths are refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: ordinary staged content passes.
|
||||
# The config is REQUIRED as of the decision gate: this fixture has no generated
|
||||
# JSON, so it records that fact with a reason. This is the migration cost of the
|
||||
# hard cutover, paid here first — these two controls were the only pre-existing
|
||||
# cases that reached the JSON stage without nominating a path, and they are
|
||||
# exactly the "repo that never wired it up" the gate now refuses.
|
||||
R="$(new_repo a3)"
|
||||
write_config "$R" '{"json_check": "none", "reason": "fixture has no generated JSON"}'
|
||||
printf 'just some code\n' > "$R/clean.txt"
|
||||
git -C "$R" add clean.txt
|
||||
expect CONTROL 0 "clean staged content passes (1 file actually scanned)" \
|
||||
--out "no conflict markers in 1 staged file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL (false-positive guard): prose using ======= as an underline must NOT trip.
|
||||
# This is why the marker pattern deliberately ignores a bare '======='.
|
||||
R="$(new_repo a4)"
|
||||
write_config "$R" '{"json_check": "none", "reason": "fixture has no generated JSON"}'
|
||||
cat > "$R/README.rst" <<'EOF'
|
||||
Section Title
|
||||
=============
|
||||
|
||||
Body text.
|
||||
|
||||
Another Heading
|
||||
=======
|
||||
EOF
|
||||
git -C "$R" add README.rst
|
||||
expect CONTROL 0 "prose using ======= underlines does not trip the guard" \
|
||||
--out "no conflict markers in 1 staged file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE (fault injection): if the scan itself ERRORS, the guard must refuse
|
||||
# rather than report a clean result. git grep exits 1 for "no match" but >=2 for
|
||||
# a real failure; a blanket '|| true' would collapse the two. We inject the
|
||||
# failure with a PATH shim rather than trying to corrupt an index.
|
||||
R="$(new_repo a5)"
|
||||
SHIM="$WORK_DIR/a5/bin"
|
||||
mkdir -p "$SHIM"
|
||||
REAL_GIT="$(command -v git)"
|
||||
cat > "$SHIM/git" <<SH
|
||||
#!/usr/bin/env bash
|
||||
if [[ "\$1" == "grep" ]]; then exit 2; fi
|
||||
exec "$REAL_GIT" "\$@"
|
||||
SH
|
||||
chmod +x "$SHIM/git"
|
||||
printf 'ordinary content\n' > "$R/thing.txt"
|
||||
git -C "$R" add thing.txt
|
||||
expect NEEDLE 2 "a conflict scan that ERRORS is refused, not reported clean" \
|
||||
--out "did not run" -- \
|
||||
bash -c "cd '$R' && export PATH=\"$SHIM:\$PATH\" && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: RENAME + MODIFY. Git reports a `git mv` plus a small edit as a single
|
||||
# 'R' entry, which --diff-filter=ACM does not match, making the file invisible to
|
||||
# every content check. A clean file is co-staged deliberately: without it the
|
||||
# file list is empty and the nothing-staged guard fires by ACCIDENT, which would
|
||||
# make this needle pass for the wrong reason and hide the real defect.
|
||||
R="$(new_repo a6)"
|
||||
mkdir -p "$R/data"
|
||||
seq 1 200 | sed 's/^/line /' > "$R/data/canon.txt"
|
||||
printf 'original\n' > "$R/other.txt"
|
||||
git -C "$R" add -A && git -C "$R" commit -q -m seed
|
||||
git -C "$R" mv data/canon.txt data/canon2.txt
|
||||
printf '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\n' >> "$R/data/canon2.txt"
|
||||
printf 'an ordinary innocent change\n' > "$R/other.txt"
|
||||
git -C "$R" add -A
|
||||
expect NEEDLE 2 "conflict markers in a RENAMED file are refused (rename+modify)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: a path marked binary in .gitattributes. `git grep -I` honours that
|
||||
# classification and skips the blob entirely while the summary still counts the
|
||||
# file as scanned — a clean pass AND a false count. Hence -a, not -I.
|
||||
R="$(new_repo a7)"
|
||||
printf 'notes.txt binary\n' > "$R/.gitattributes"
|
||||
printf 'x\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> b\ny\n' > "$R/notes.txt"
|
||||
git -C "$R" add -A
|
||||
expect NEEDLE 2 "conflict markers in a .gitattributes-binary file are refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
echo "=== (c) staged JSON parses ==="
|
||||
|
||||
# NEEDLE: malformed JSON, the shape a broken generator emits.
|
||||
R="$(new_repo c1)"
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1,\n' > "$R/data/broken.json"
|
||||
git -C "$R" add data/broken.json
|
||||
expect NEEDLE 3 "staged unparseable JSON under --json-path is refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
||||
|
||||
# NEEDLE: conflict markers inside a generated JSON file — the 70-file incident.
|
||||
R="$(new_repo c2)"
|
||||
mkdir -p "$R/data"
|
||||
cat > "$R/data/canon.json" <<'EOF'
|
||||
{
|
||||
<<<<<<< HEAD
|
||||
"v": 1
|
||||
=======
|
||||
"v": 2
|
||||
>>>>>>> main
|
||||
}
|
||||
EOF
|
||||
git -C "$R" add data/canon.json
|
||||
expect NEEDLE 2 "conflict markers in generated JSON are refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: valid JSON passes.
|
||||
R="$(new_repo c3)"
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1, "b": [2, 3]}\n' > "$R/data/good.json"
|
||||
git -C "$R" add data/good.json
|
||||
expect CONTROL 0 "valid staged JSON passes (and was actually parsed)" \
|
||||
--out "1 staged .json file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
||||
|
||||
# CONTROL: an explicit exemption works (and is reported, never silent).
|
||||
R="$(new_repo c4)"
|
||||
mkdir -p "$R/fixtures"
|
||||
printf 'not json at all' > "$R/fixtures/malformed.json"
|
||||
git -C "$R" add fixtures/malformed.json
|
||||
expect CONTROL 0 "deliberate malformed fixture can be exempted (exemption announced)" \
|
||||
--out "EXEMPTED by --allow-invalid-json" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'fixtures/*' --allow-invalid-json 'fixtures/*'"
|
||||
|
||||
# CONTROL (false-positive regression): JSONC is the common real-world case.
|
||||
# tsconfig.json legally carries comments and does NOT parse strictly. Measured on
|
||||
# a real repo: 17 of 119 tracked .json files are like this. An on-by-default
|
||||
# check would fire on all of them; scoping by --json-path is what prevents it.
|
||||
R="$(new_repo c5)"
|
||||
cat > "$R/tsconfig.json" <<'EOF'
|
||||
{
|
||||
// JSONC: comments are legal here and strict json.load() rejects them.
|
||||
"compilerOptions": { "strict": true }
|
||||
}
|
||||
EOF
|
||||
mkdir -p "$R/data"
|
||||
printf '{"generated": true}\n' > "$R/data/view.json"
|
||||
git -C "$R" add tsconfig.json data/view.json
|
||||
expect CONTROL 0 "JSONC outside --json-path does not trip the JSON check" \
|
||||
--out "1 staged .json file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
||||
|
||||
# NEEDLE: the same tsconfig DOES fail if someone wrongly nominates it, proving
|
||||
# the check is genuinely running and the control above is not a vacuous pass.
|
||||
expect NEEDLE 3 "the same JSONC file fails when explicitly nominated" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'tsconfig.json'"
|
||||
|
||||
echo "=== (d) the JSON decision is MANDATORY (.push-guard.json) ==="
|
||||
|
||||
# NEEDLE: the fail-open this gate closes. No --json-path, no config: the previous
|
||||
# release printed "JSON check NOT REQUESTED" and exited 0, leaving the repo
|
||||
# unprotected forever with nothing ever failing.
|
||||
R="$(new_repo d1)"
|
||||
printf '{"a": 1}\n' > "$R/thing.json"
|
||||
git -C "$R" add thing.json
|
||||
expect NEEDLE 6 "no --json-path and no config is REFUSED (the closed fail-open)" \
|
||||
--out "NO JSON DECISION" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: config nominates paths, and the check genuinely runs off it.
|
||||
R="$(new_repo d2)"
|
||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1,\n' > "$R/data/broken.json"
|
||||
git -C "$R" add data/broken.json
|
||||
expect NEEDLE 3 "broken JSON is caught via config-supplied json_paths" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: opt-out WITHOUT a reason. An unexplained opt-out is the absence again,
|
||||
# merely relocated into a file.
|
||||
R="$(new_repo d3)"
|
||||
write_config "$R" '{"json_check": "none"}'
|
||||
printf 'code\n' > "$R/x.txt"
|
||||
git -C "$R" add x.txt
|
||||
expect NEEDLE 6 "json_check:none without a reason is refused" \
|
||||
--out "REQUIRES a non-empty" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: malformed config must REFUSE, never fall back to "treat as absent".
|
||||
# That fallback would mean a typo silently disables the check the file enables.
|
||||
R="$(new_repo d4)"
|
||||
write_config "$R" '{"json_paths": ["data/**/*.json",'
|
||||
printf 'code\n' > "$R/x.txt"
|
||||
git -C "$R" add x.txt
|
||||
expect NEEDLE 6 "an unparseable config is refused, not treated as absent" \
|
||||
--out "INVALID" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: a config that parses but states NO decision. Distinct from malformed —
|
||||
# this one is valid JSON and still says nothing, which is the original defect
|
||||
# wearing a config file as a disguise.
|
||||
R="$(new_repo d5)"
|
||||
write_config "$R" '{}'
|
||||
printf 'code\n' > "$R/x.txt"
|
||||
git -C "$R" add x.txt
|
||||
expect NEEDLE 6 "a valid config that states no decision is refused" \
|
||||
--out "states no decision" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# NEEDLE: a bogus json_check value must not be read as an opt-out.
|
||||
R="$(new_repo d6)"
|
||||
write_config "$R" '{"json_check": "off", "reason": "typo for none"}'
|
||||
printf 'code\n' > "$R/x.txt"
|
||||
git -C "$R" add x.txt
|
||||
expect NEEDLE 6 'json_check with an unrecognised value is refused' \
|
||||
--out 'must be "none"' -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: config nominates paths, JSON is clean.
|
||||
# --out is REQUIRED. Exit 0 alone cannot distinguish "read the config, resolved
|
||||
# the paths, parsed the files" from "matched nothing and had nothing to do" —
|
||||
# the vacuous-control trap that already caught this harness once.
|
||||
R="$(new_repo d7)"
|
||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
||||
mkdir -p "$R/data"
|
||||
printf '{"generated": true}\n' > "$R/data/view.json"
|
||||
git -C "$R" add data/view.json
|
||||
expect CONTROL 0 "config-supplied json_paths pass and are reported as parsed" \
|
||||
--out "1 staged .json file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: a recorded opt-out passes AND the reason is echoed. Asserting on the
|
||||
# reason is the whole point — an opt-out nobody can see is what we just removed.
|
||||
R="$(new_repo d8)"
|
||||
write_config "$R" '{"json_check": "none", "reason": "no generated JSON in this repo"}'
|
||||
printf 'code\n' > "$R/x.txt"
|
||||
git -C "$R" add x.txt
|
||||
expect CONTROL 0 "a recorded opt-out passes and PRINTS its reason" \
|
||||
--out "recorded reason: no generated JSON in this repo" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: config-supplied exemptions still work through the config path.
|
||||
R="$(new_repo d9)"
|
||||
write_config "$R" '{"json_paths": ["fixtures/*"], "allow_invalid_json": ["fixtures/*"]}'
|
||||
mkdir -p "$R/fixtures"
|
||||
printf 'not json at all' > "$R/fixtures/malformed.json"
|
||||
git -C "$R" add fixtures/malformed.json
|
||||
expect CONTROL 0 "config-supplied allow_invalid_json exempts (and announces it)" \
|
||||
--out "EXEMPTED by --allow-invalid-json" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: --json-path alone still satisfies the decision, with no config at all.
|
||||
# This is what keeps every pre-existing caller working: nominating a path IS an
|
||||
# explicit decision. Only saying nothing is refused.
|
||||
R="$(new_repo d10)"
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1}\n' > "$R/data/good.json"
|
||||
git -C "$R" add data/good.json
|
||||
expect CONTROL 0 "--json-path alone satisfies the decision with no config present" \
|
||||
--out "1 staged .json file(s)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
||||
|
||||
# NEEDLE: a CLI nomination must WIN over a config opt-out, loudly. Resolving a
|
||||
# contradiction toward more checking is the only safe direction, but doing it
|
||||
# silently would hide a genuine disagreement between caller and repo.
|
||||
R="$(new_repo d11)"
|
||||
write_config "$R" '{"json_check": "none", "reason": "claims no generated JSON"}'
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1,\n' > "$R/data/broken.json"
|
||||
git -C "$R" add data/broken.json
|
||||
expect NEEDLE 3 "--json-path overrides a config opt-out and still catches bad JSON" \
|
||||
--out "honouring the nomination" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/**/*.json'"
|
||||
|
||||
echo "=== (z) the guard itself emits no stray output ==="
|
||||
|
||||
# CONTROL: the guard must not print interpreter warnings.
|
||||
#
|
||||
# This case exists because a real one shipped: the config parser was an inline
|
||||
# `<<'PY'` heredoc inside a $( ) command substitution, so bash printed
|
||||
# warning: command substitution: 1 unterminated here-document
|
||||
# on EVERY run. Thirty needles and a clean shellcheck all missed it — the
|
||||
# warning went to stderr, and no assertion in this harness looked at output it
|
||||
# had not already been told to expect. It was found only by running the guard
|
||||
# against a real repository.
|
||||
#
|
||||
# The lesson is narrow and worth encoding: asserting on what you EXPECT to see
|
||||
# cannot detect what you never thought to look for. This asserts on the absence
|
||||
# of a whole output class instead.
|
||||
R="$(new_repo z1)"
|
||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
||||
mkdir -p "$R/data"
|
||||
printf '{"a": 1}\n' > "$R/data/view.json"
|
||||
git -C "$R" add data/view.json
|
||||
z_out="$(cd "$R" && "$GUARD" check-staged 2>&1)"
|
||||
if grep -qiE 'warning:|unterminated|command substitution' <<<"$z_out"; then
|
||||
printf ' FAIL [%-7s] %s\n' "CONTROL" "guard emits interpreter warnings"
|
||||
printf '%s\n' "$z_out" | sed 's/^/ | /'
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
printf ' PASS [%-7s] %s\n' "CONTROL" "guard runs without emitting any interpreter warning"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
|
||||
# NEEDLE for the case above: prove the detector can actually fire, otherwise it
|
||||
# is one more assertion that passes because it looked at nothing.
|
||||
if grep -qiE 'warning:|unterminated|command substitution' \
|
||||
<<<"bash: warning: command substitution: 1 unterminated here-document"; then
|
||||
printf ' PASS [%-7s] %s\n' "NEEDLE" "the warning detector fires on a known warning string"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf ' FAIL [%-7s] %s\n' "NEEDLE" "the warning detector is dead — it cannot fire"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "=== null case: nothing staged ==="
|
||||
|
||||
# NEEDLE: the index equals HEAD. Committing here produces the empty commit.
|
||||
R="$(new_repo n1)"
|
||||
expect NEEDLE 5 "empty index is refused before a commit happens" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
echo "=== (b) push actually happened ==="
|
||||
|
||||
# CONTROL: a real push that moves the remote.
|
||||
R="$(new_repo b1)"
|
||||
printf 'work\n' > "$R/work.txt"
|
||||
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
|
||||
expect CONTROL 0 "a push that moves the remote is confirmed" \
|
||||
--out "PUSH CONFIRMED" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
|
||||
|
||||
# NEEDLE: THE ORIGINAL FALSE POSITIVE. Remote already equals local HEAD, so the
|
||||
# naive 'remote == local' assertion succeeds while nothing is transferred.
|
||||
R="$(new_repo b2)"
|
||||
printf 'work\n' > "$R/work.txt"
|
||||
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
|
||||
git -C "$R" push -q origin HEAD:refs/heads/main
|
||||
expect NEEDLE 4 "second push transferring nothing is refused (remote did not move)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
|
||||
|
||||
# NEEDLE: the commit step aborted, so HEAD never advanced.
|
||||
R="$(new_repo b3)"
|
||||
HEAD_BEFORE="$(git -C "$R" rev-parse HEAD)"
|
||||
expect NEEDLE 5 "push after an aborted commit is refused (HEAD did not advance)" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main --since-head '$HEAD_BEFORE'"
|
||||
|
||||
# NEEDLE: an empty commit carrying a real message.
|
||||
R="$(new_repo b4)"
|
||||
git -C "$R" commit -q --allow-empty -m "feat: important-sounding message"
|
||||
expect NEEDLE 5 "pushing an empty commit is refused" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main"
|
||||
|
||||
# CONTROL: --since-head is satisfied when a real commit was made.
|
||||
R="$(new_repo b5)"
|
||||
HEAD_BEFORE="$(git -C "$R" rev-parse HEAD)"
|
||||
printf 'work\n' > "$R/work.txt"
|
||||
git -C "$R" add work.txt && git -C "$R" commit -q -m "real work"
|
||||
# --out is required here. Without it this control is VACUOUS: deleting the whole
|
||||
# --since-head validation block would still yield exit 0, because the empty-commit
|
||||
# and remote-moved checks independently succeed. It would prove nothing about the
|
||||
# code path it names.
|
||||
expect CONTROL 0 "--since-head passes when a real commit was created" \
|
||||
--out "HEAD advanced" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch main --since-head '$HEAD_BEFORE'"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Needles for the five blockers rev-974 found from a clean checkout. Every one
|
||||
# of these was reproduced before it was fixed; none is a hypothesis.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
echo
|
||||
echo "-- blocker 2: the enumerating producer's exit status --"
|
||||
# FAULT INJECTION. mapfile < <(git diff) reported MAPFILE's status, so a git diff
|
||||
# that died returned zero files and the guard published that silence as clean.
|
||||
R="$(new_repo e1)"
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect NEEDLE 6 "a failed file enumeration REFUSES instead of reporting clean" \
|
||||
--out "CANNOT ENUMERATE STAGED FILES" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path ':(this-magic-does-not-exist)data/*.json'"
|
||||
# CONTROL: the same malformed file with a WORKING pathspec must still be caught,
|
||||
# so the needle above is not passing merely because everything now refuses.
|
||||
expect CONTROL 3 "a working pathspec still catches the malformed JSON" \
|
||||
--out "data/bad.json" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged --json-path 'data/*.json'"
|
||||
|
||||
echo
|
||||
echo "-- blocker 3: OFF must come from a committed, reviewable object --"
|
||||
R="$(new_repo e2)"
|
||||
write_config_untracked "$R" '{"json_check": "none", "reason": "local unreviewed bypass"}'
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
# The anchor is the DISTINGUISHING clause, not the shared headline. Three
|
||||
# separate branches print "OPT-OUT IS NOT REVIEWABLE" — untracked, staged-not-
|
||||
# committed, and symlink. Anchoring on the headline means this needle stays green
|
||||
# if the untracked branch is deleted and control falls through to a SIBLING that
|
||||
# prints the same words: a substring anchor does not fail when its subject is
|
||||
# removed, IT RE-POINTS. Anchor on the clause only this branch can produce.
|
||||
expect NEEDLE 6 "an UNTRACKED opt-out is refused as unreviewable" \
|
||||
--out "is not tracked in git" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# STAGED BUT NOT COMMITTED. Found by mutation, not by review: `if [[ -z "$cmode" ]]`
|
||||
# survived `if false` against a 44/44 green suite, because no case ever built this
|
||||
# state. Note the mutant still exits 6 — control falls to the LOCAL-ONLY branch
|
||||
# below and refuses for a different reason. An exit-code-only assertion here would
|
||||
# be satisfied by the wrong branch, which is why --out carries the distinguishing
|
||||
# clause. `git add` puts the file in the index, so ls-files matches while HEAD
|
||||
# does not: staged review is not review.
|
||||
R="$(new_repo e2b)"
|
||||
write_config_untracked "$R" '{"json_check": "none", "reason": "staged, never committed"}'
|
||||
git -C "$R" add .push-guard.json
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect NEEDLE 6 "a STAGED-but-uncommitted opt-out is refused" \
|
||||
--out "staged but not yet in HEAD" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# COMMITTED CONFIG DOES NOT PARSE while the working tree opts out cleanly. Also a
|
||||
# mutation survivor. The working-tree config is valid, so the early config check
|
||||
# passes and we reach the re-read; the committed object is what fails. Without
|
||||
# this branch an opt-out could be honoured on the strength of a HEAD blob nobody
|
||||
# can actually read a decision out of.
|
||||
R="$(new_repo e2c)"
|
||||
write_config "$R" '{ this is not json'
|
||||
printf '%s\n' '{"json_check": "none", "reason": "valid here, broken in HEAD"}' > "$R/.push-guard.json"
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect NEEDLE 6 "an UNPARSEABLE committed config cannot authorise an opt-out" \
|
||||
--out "is INVALID" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# A committed ON silently flipped OFF in the working tree is the same bypass.
|
||||
R="$(new_repo e3)"
|
||||
write_config "$R" '{"json_paths": ["data/**/*.json"]}'
|
||||
printf '%s\n' '{"json_check": "none", "reason": "local convenience"}' > "$R/.push-guard.json"
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect NEEDLE 6 "a committed ON flipped OFF in the working tree is refused" \
|
||||
--out "LOCAL-ONLY OPT-OUT" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# A committed SYMLINK is a mutable target: the reviewed blob is a path, and what
|
||||
# it points at can change with no diff at all.
|
||||
R="$(new_repo e4)"
|
||||
printf '%s\n' '{"json_check": "none", "reason": "via symlink"}' > "$R/real-config.json"
|
||||
ln -s real-config.json "$R/.push-guard.json"
|
||||
git -C "$R" add real-config.json .push-guard.json
|
||||
git -C "$R" commit -q -m "symlinked config"
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect NEEDLE 6 "a committed SYMLINK config is refused as a mutable target" \
|
||||
--out "committed SYMLINK" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
# CONTROL: the supported path still works. Without this the whole opt-out feature
|
||||
# could be dead and every needle above would still pass — a gate that can never
|
||||
# say yes is not a gate.
|
||||
R="$(new_repo e5)"
|
||||
write_config "$R" '{"json_check": "none", "reason": "committed and reviewable"}'
|
||||
mkdir -p "$R/data"; printf '{ not json' > "$R/data/bad.json"
|
||||
git -C "$R" add -f data/bad.json
|
||||
expect CONTROL 0 "a COMMITTED opt-out is honoured and prints its committed reason" \
|
||||
--out "recorded reason: committed and reviewable" -- \
|
||||
bash -c "cd '$R' && '$GUARD' check-staged"
|
||||
|
||||
echo
|
||||
echo "-- blocker 4: advancement is ancestry, not inequality --"
|
||||
R="$(new_repo e6)"
|
||||
git -C "$R" checkout -qb other
|
||||
printf 'o\n' > "$R/o.txt"; git -C "$R" add o.txt; git -C "$R" commit -q -m o
|
||||
OTHER="$(git -C "$R" rev-parse HEAD)"
|
||||
git -C "$R" checkout -q main 2>/dev/null || git -C "$R" checkout -q master
|
||||
printf 'm\n' > "$R/m.txt"; git -C "$R" add m.txt; git -C "$R" commit -q -m m
|
||||
MAINH="$(git -C "$R" rev-parse HEAD)"
|
||||
git -C "$R" checkout -q "$OTHER"
|
||||
expect NEEDLE 5 "a checkout of pre-existing diverged history is not 'advancement'" \
|
||||
--out "NOT A DESCENDANT" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch other --since-head '$MAINH'"
|
||||
|
||||
echo
|
||||
echo "-- blocker 5: root and merge commits have defined emptiness --"
|
||||
R="$(new_repo e7)"
|
||||
git -C "$R" checkout -q --orphan fresh
|
||||
git -C "$R" rm -q -rf . >/dev/null 2>&1 || true
|
||||
git -C "$R" commit -q --allow-empty -m "empty root"
|
||||
expect NEEDLE 5 "an EMPTY ROOT commit is refused, not exempted" \
|
||||
--out "EMPTY ROOT COMMIT" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch fresh"
|
||||
|
||||
# CONTROL: a real root commit must still push, or the fix is just a new wall.
|
||||
R="$(new_repo e8)"
|
||||
git -C "$R" checkout -q --orphan fresh2
|
||||
git -C "$R" rm -q -rf . >/dev/null 2>&1 || true
|
||||
printf 'real\n' > "$R/real.txt"; git -C "$R" add real.txt
|
||||
git -C "$R" commit -q -m "real root"
|
||||
expect CONTROL 0 "a NON-empty root commit still pushes" \
|
||||
--out "non-empty root commit" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch fresh2"
|
||||
|
||||
# THE MERGE BRANCH HAD NO NEEDLE AT ALL. It was defined, hand-verified, and
|
||||
# shipped -- and deleting the refusal left the suite at 41/41 green. Defining
|
||||
# semantics is not testing them, and an untested branch is indistinguishable
|
||||
# from an absent one to everyone downstream.
|
||||
# EMPTY MERGE: two branches that each change nothing, merged. The merge tree is
|
||||
# then identical to EVERY parent -- it integrates nothing and introduces nothing.
|
||||
R="$(new_repo e9)"
|
||||
git -C "$R" checkout -q -b mx
|
||||
git -C "$R" commit -q --allow-empty -m "x: no tree change"
|
||||
git -C "$R" checkout -q -b my HEAD~1 2>/dev/null || git -C "$R" checkout -q -b my
|
||||
git -C "$R" commit -q --allow-empty -m "y: no tree change"
|
||||
git -C "$R" checkout -q mx
|
||||
git -C "$R" merge -q --no-ff --no-edit my
|
||||
# PROVE THE FIXTURE IS ACTUALLY AN EMPTY MERGE before asserting on it, or the
|
||||
# needle passes for the wrong reason on a repo that never made a merge at all.
|
||||
np="$(git -C "$R" rev-list --parents -n 1 HEAD | wc -w)"
|
||||
if (( np > 2 )) && git -C "$R" diff --quiet HEAD^1 HEAD && git -C "$R" diff --quiet HEAD^2 HEAD; then
|
||||
printf ' ok [%-14s] fixture is a merge (%d fields) with tree identical to both parents\n' "e9-fixture" "$np"; PASS=$(( PASS + 1 ))
|
||||
else
|
||||
printf ' FAIL [%-14s] fixture is not an empty merge -- needle would be vacuous\n' "e9-fixture"; FAIL=$(( FAIL + 1 ))
|
||||
fi
|
||||
expect NEEDLE 5 "an EMPTY MERGE is refused, not exempted" \
|
||||
--out "EMPTY MERGE" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch mx"
|
||||
|
||||
# CONTROL: a merge that really integrates must still push. Both sides add a
|
||||
# distinct file, so the merge tree differs from BOTH parents.
|
||||
R="$(new_repo e10)"
|
||||
git -C "$R" checkout -q -b nx
|
||||
printf 'from x\n' > "$R/x.txt"; git -C "$R" add x.txt; git -C "$R" commit -q -m "x adds a file"
|
||||
git -C "$R" checkout -q -b ny HEAD~1
|
||||
printf 'from y\n' > "$R/y.txt"; git -C "$R" add y.txt; git -C "$R" commit -q -m "y adds a file"
|
||||
git -C "$R" checkout -q nx
|
||||
git -C "$R" merge -q --no-ff --no-edit ny
|
||||
expect CONTROL 0 "a NON-empty merge commit still pushes" \
|
||||
--out "non-empty merge commit" -- \
|
||||
bash -c "cd '$R' && '$GUARD' push --remote origin --branch nx"
|
||||
|
||||
echo
|
||||
printf 'push-guard needles: %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
(( FAIL == 0 )) || exit 1
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-verify-clean-clone.sh -- needles for the verifier itself.
|
||||
#
|
||||
# v1 of the verifier passed while the real repository recorded 100644, because it
|
||||
# asserted a SCRATCH repo built by cp. There was no needle that could have caught
|
||||
# that: every case ran against a tree whose modes came off my filesystem.
|
||||
# So the load-bearing needle here is w2 -- SOURCE GIT MODE 100644, DISK MODE 755.
|
||||
# That is the exact contaminated state, and v1 exits 0 on it while v2 must refuse.
|
||||
# A verifier without this needle is the same shape as the defect it verifies.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERIFY="$HERE/verify-clean-clone.sh"
|
||||
ARTIFACTS=(push-guard.sh test-push-guard.sh verify-clean-clone.sh mutate-push-guard.sh
|
||||
test-mutate-push-guard.sh
|
||||
test-verify-clean-clone.sh)
|
||||
|
||||
# THE REAL DEPLOYED LAYOUT. Every fixture in the first version of this file
|
||||
# installed the artifacts at the fixture ROOT, so 6/6 green proved flat-layout
|
||||
# operation and said NOTHING about the path these files actually ship at. The
|
||||
# verifier was unusable in place and the suite could not see it, because THE
|
||||
# FIXTURE ENCODED A LAYOUT THAT DOES NOT EXIST. A fixture is a claim about the
|
||||
# world; an untested fixture is an unreviewed one.
|
||||
readonly REAL_PREFIX="packages/mosaic/framework/tools/git"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Build a source repo whose COMMITTED modes are 100755 and whose disk modes are
|
||||
# executable -- the honest state.
|
||||
# mkrepo <repo-root> [subdir]
|
||||
# With a subdir the artifacts are committed at repo/<subdir>/, which is how they
|
||||
# really ship. Echoes the directory the artifacts landed in -- that is what gets
|
||||
# passed as --repo, so the caller never has to reconstruct the path.
|
||||
mkrepo() {
|
||||
local d="$1" sub="${2:-}"
|
||||
local dest="$d${sub:+/$sub}"
|
||||
mkdir -p "$dest"
|
||||
local f
|
||||
for f in "${ARTIFACTS[@]}"; do
|
||||
install -m 755 "$HERE/$f" "$dest/$f"
|
||||
done
|
||||
git -C "$d" init -q .
|
||||
git -C "$d" config user.email "[email protected]"
|
||||
git -C "$d" config user.name "Verifier Needles"
|
||||
git -C "$d" add -A
|
||||
git -C "$d" commit -q -m "artifacts"
|
||||
printf '%s\n' "$dest"
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local name="$1" want_rc="$2" want_txt="$3" repo="$4"
|
||||
local out rc
|
||||
out="$("$VERIFY" --repo "$repo" 2>&1)"; rc=$?
|
||||
if (( rc == want_rc )) && [[ "$out" == *"$want_txt"* ]]; then
|
||||
printf ' ok [%-14s]\n' "$name"; PASS=$(( PASS + 1 ))
|
||||
else
|
||||
printf ' FAIL [%-14s] wanted rc=%d containing %q; got rc=%d\n%s\n' \
|
||||
"$name" "$want_rc" "$want_txt" "$rc" "$out"; FAIL=$(( FAIL + 1 ))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "== POSITIVE CONTROL: an honestly-committed artifact must PASS =="
|
||||
# Without this, every case below could be passing because the verifier always
|
||||
# refuses -- a wall, not a gate.
|
||||
mkrepo "$TMP/good" >/dev/null
|
||||
run_case w1-honest 0 "the COMMITTED artifact ran and passed" "$TMP/good"
|
||||
|
||||
echo
|
||||
echo "== THE DEPLOYED LAYOUT: artifacts NESTED, not at the repository root =="
|
||||
# The blocker: ls-tree was given a bare basename, which is a ROOT-relative
|
||||
# pathspec, so in the real tree every artifact came back NOT TRACKED and the
|
||||
# verifier refused to run at all. This control fails against that version and
|
||||
# passes only when the committed PREFIX is threaded through ls-tree, the cloned
|
||||
# stat, and the suite's working directory.
|
||||
w6dir="$(mkrepo "$TMP/nested" "$REAL_PREFIX")"
|
||||
run_case w6-nested 0 "the COMMITTED artifact ran and passed" "$w6dir"
|
||||
# ...and prove the run actually happened DOWN THERE rather than at the root, or
|
||||
# a passing w6 would only mean the prefix was ignored harmlessly.
|
||||
#
|
||||
# Captured into a variable rather than piped into `grep -q` ON PURPOSE. grep -q
|
||||
# exits at the FIRST match, the verifier then dies of SIGPIPE, and under
|
||||
# `pipefail` the pipeline reports that 141 -- so A SUCCESSFUL MATCH READS AS A
|
||||
# FAILED ASSERTION. This cost me a red w6-prefix against a verifier that was
|
||||
# printing the right prefix all along. Same family as the pipefail/process-
|
||||
# substitution note already on record: the exit status being consulted is not
|
||||
# the status of the thing being asserted.
|
||||
w6out="$("$VERIFY" --repo "$w6dir" 2>&1)"
|
||||
if [[ "$w6out" == *"prefix $REAL_PREFIX/"* ]]; then
|
||||
printf ' ok [%-14s] verifier reported prefix %s/\n' "w6-prefix" "$REAL_PREFIX"; PASS=$(( PASS + 1 ))
|
||||
else
|
||||
printf ' FAIL [%-14s] verifier did not report the nested prefix -- it may have\n' "w6-prefix"
|
||||
printf ' passed by looking at the root, which is the blocker unfixed\n'; FAIL=$(( FAIL + 1 ))
|
||||
fi
|
||||
# And the mode needle must ALSO bite in the nested layout: a prefix threaded
|
||||
# into the clone but not into ls-tree would silently stop checking modes.
|
||||
w7dir="$(mkrepo "$TMP/nested-laundered" "$REAL_PREFIX")"
|
||||
git -C "$TMP/nested-laundered" update-index --chmod=-x "$REAL_PREFIX/push-guard.sh"
|
||||
git -C "$TMP/nested-laundered" commit -q -m "drop exec bit in git only"
|
||||
run_case w7-nested-mode 1 "committed 100644, needs 100755" "$w7dir"
|
||||
|
||||
echo
|
||||
echo "== THE B1 NEEDLE: source git mode 100644, DISK MODE STILL 755 =="
|
||||
# This is the reviewer's exact reproduction. v1 of the verifier exits 0 here.
|
||||
mkrepo "$TMP/laundered" >/dev/null
|
||||
git -C "$TMP/laundered" update-index --chmod=-x push-guard.sh test-push-guard.sh
|
||||
git -C "$TMP/laundered" commit -q -m "drop exec bit in git only"
|
||||
# PROVE THE FIXTURE IS THE CONTAMINATED STATE, not merely a broken repo: git must
|
||||
# say 100644 while the filesystem still says executable. If the disk bit were
|
||||
# gone too, the needle would be testing something easier than the real defect.
|
||||
src_mode="$(git -C "$TMP/laundered" ls-tree HEAD -- push-guard.sh | awk '{print $1}')"
|
||||
disk_mode="$(stat -c '%a' "$TMP/laundered/push-guard.sh")"
|
||||
if [[ "$src_mode" == "100644" && "$disk_mode" == "755" ]]; then
|
||||
printf ' ok [%-14s] fixture is git=%s disk=%s\n' "w2-fixture" "$src_mode" "$disk_mode"; PASS=$(( PASS + 1 ))
|
||||
else
|
||||
printf ' FAIL [%-14s] fixture wrong: git=%s disk=%s -- needle would not test the defect\n' \
|
||||
"w2-fixture" "$src_mode" "$disk_mode"; FAIL=$(( FAIL + 1 ))
|
||||
fi
|
||||
run_case w2-laundered 1 "committed 100644, needs 100755" "$TMP/laundered"
|
||||
|
||||
echo
|
||||
echo "== the artifact must be COMMITTED, or there is no mode to verify =="
|
||||
mkrepo "$TMP/untracked" >/dev/null
|
||||
git -C "$TMP/untracked" rm -q --cached push-guard.sh
|
||||
git -C "$TMP/untracked" commit -q -m "untrack the guard"
|
||||
run_case w3-untracked 1 "NOT TRACKED" "$TMP/untracked"
|
||||
|
||||
echo
|
||||
echo "== a committed SYMLINK is a path, not the reviewed code =="
|
||||
mkrepo "$TMP/symlink" >/dev/null
|
||||
( cd "$TMP/symlink" && rm -f push-guard.sh && ln -s /dev/null push-guard.sh \
|
||||
&& git add push-guard.sh && git commit -q -m "symlink the guard" )
|
||||
run_case w4-symlink 1 "SYMLINK" "$TMP/symlink"
|
||||
|
||||
echo
|
||||
echo "== not a repository at all: REFUSE, never pass =="
|
||||
mkdir -p "$TMP/bare"
|
||||
for f in "${ARTIFACTS[@]}"; do install -m 755 "$HERE/$f" "$TMP/bare/$f"; done
|
||||
run_case w5-norepo 1 "not inside a git repository" "$TMP/bare"
|
||||
|
||||
echo
|
||||
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
(( FAIL == 0 ))
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# verify-clean-clone.sh -- prove the COMMITTED artifact runs, from a clean clone.
|
||||
#
|
||||
# ================== THIS FILE IS THE SECOND VERSION. THE FIRST HAD B1. ========
|
||||
# B1 was: the delivered scripts were committed mode 100644, so the artifact
|
||||
# returned 126 Permission denied for anyone who cloned it. Two of us ran 32/32
|
||||
# because our local working copies had the exec bit set by hand at creation.
|
||||
#
|
||||
# I wrote v1 of this file to prevent exactly that. V1 COPIED THE WORKING-TREE
|
||||
# FILES INTO A SCRATCH REPOSITORY AND ASSERTED THE SCRATCH REPOSITORY'S INDEX.
|
||||
# cp preserves the local exec bit, so `git add` in the scratch repo recorded
|
||||
# 100755 NO MATTER WHAT THE REAL REPOSITORY STORED. Reviewer reproduction:
|
||||
# git update-index --chmod=-x push-guard.sh test-push-guard.sh
|
||||
# ./verify-clean-clone.sh -> EXIT 0, "CLEAN CLONE: suite ran and passed"
|
||||
# while the real index read 100644 -- the precise contaminated state B1 was.
|
||||
#
|
||||
# THE CONTROL FOR THE DEFECT INHERITED THE DEFECT, BECAUSE IT MEASURED A COPY
|
||||
# INSTEAD OF THE SUBJECT. cp LAUNDERS MODE.
|
||||
#
|
||||
# Both of v1's mechanisms were right -- assert the INDEX not the disk, execute
|
||||
# DIRECTLY not via `bash script`. They were applied to the wrong repository.
|
||||
# So v2 changes what is measured, not how:
|
||||
# * mode is read from the SOURCE repository's COMMITTED TREE (git ls-tree),
|
||||
# which no local chmod can influence;
|
||||
# * the tree under test is produced by CLONING THAT COMMIT, so every mode
|
||||
# comes out of the object store rather than off my filesystem.
|
||||
# There is no cp anywhere in this file, and that is deliberate.
|
||||
#
|
||||
# It also REFUSES rather than passes when the artifacts are untracked: an
|
||||
# artifact that is not committed has no mode to verify, and a verifier that
|
||||
# silently succeeds on nothing is the vacuous-absence failure all over again.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly EX_FAIL=1
|
||||
readonly EX_USAGE=64
|
||||
|
||||
REPO=""
|
||||
REV="HEAD"
|
||||
|
||||
die() { printf 'usage error: %s\n' "$1" >&2; exit "$EX_USAGE"; }
|
||||
|
||||
while (( $# )); do
|
||||
case "$1" in
|
||||
--repo) REPO="${2:-}"; shift 2 ;;
|
||||
--rev) REV="${2:-}"; shift 2 ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
[[ -n "$REPO" ]] || REPO="$HERE"
|
||||
|
||||
ARTIFACTS=(push-guard.sh test-push-guard.sh verify-clean-clone.sh mutate-push-guard.sh
|
||||
test-mutate-push-guard.sh
|
||||
test-verify-clean-clone.sh)
|
||||
SUITE="test-push-guard.sh"
|
||||
|
||||
# --- the subject must be a real repository, or there is nothing to verify -----
|
||||
if ! ROOT="$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null)"; then
|
||||
printf 'REFUSING: %s is not inside a git repository.\n' "$REPO" >&2
|
||||
printf 'This verifier checks the COMMITTED mode of the artifact. An uncommitted\n' >&2
|
||||
printf 'artifact has no committed mode, and passing here would prove nothing.\n' >&2
|
||||
exit "$EX_FAIL"
|
||||
fi
|
||||
if ! REV_SHA="$(git -C "$ROOT" rev-parse --verify --quiet "${REV}^{commit}")"; then
|
||||
printf 'REFUSING: %s does not resolve to a commit in %s\n' "$REV" "$ROOT" >&2
|
||||
exit "$EX_FAIL"
|
||||
fi
|
||||
|
||||
# --- THE ARTIFACT'S COMMITTED PATH, NOT ITS BASENAME -------------------------
|
||||
# v2 asserted `git ls-tree HEAD -- push-guard.sh`, which is a ROOT-RELATIVE
|
||||
# pathspec. These files really live several directories down. Run in place and
|
||||
# every artifact came back "NOT TRACKED" -- the verifier built to stop packaging
|
||||
# false-greens could not verify the tree that ships it.
|
||||
#
|
||||
# The tests missed it because EVERY FIXTURE INSTALLED THE ARTIFACTS AT THE
|
||||
# FIXTURE ROOT. 6/6 green proved flat-layout operation and nothing about the
|
||||
# deployed path. THAT IS THE THIRD TIME ON THIS TOOL THAT A CONTROL VALIDATED A
|
||||
# MODEL INSTEAD OF THE SUBJECT: v1 measured a cp'd scratch repo, and then v2's
|
||||
# own tests measured a layout that does not exist. A fixture is a claim about
|
||||
# the world, and an untested fixture is an unreviewed one.
|
||||
#
|
||||
# --show-prefix answers "where is this directory inside its repository", so the
|
||||
# same prefix drives ls-tree, the cloned stat, and the suite's working dir.
|
||||
PREFIX="$(git -C "$REPO" rev-parse --show-prefix)"
|
||||
|
||||
printf '=== subject ===\n'
|
||||
printf ' repo %s\n rev %s (%s)\n prefix %s\n\n' \
|
||||
"$ROOT" "$REV" "$REV_SHA" "${PREFIX:-<repository root>}"
|
||||
|
||||
# --- 1. MODE, FROM THE COMMITTED TREE OF THE SUBJECT -------------------------
|
||||
# git ls-tree reports what the COMMIT records. Nothing on my filesystem can
|
||||
# move this number -- which is the whole point, and what v1 got wrong.
|
||||
printf '=== committed modes (git ls-tree %s) ===\n' "$REV"
|
||||
rc=0
|
||||
for f in "${ARTIFACTS[@]}"; do
|
||||
entry="$(git -C "$ROOT" ls-tree "$REV_SHA" -- "${PREFIX}${f}")"
|
||||
if [[ -z "$entry" ]]; then
|
||||
printf ' FAIL %s is NOT TRACKED at %s -- nothing committed to verify\n' "$f" "$REV"
|
||||
rc=1; continue
|
||||
fi
|
||||
mode="${entry%% *}"; rest="${entry#* }"; type="${rest%% *}"
|
||||
if [[ "$type" != blob ]]; then
|
||||
printf ' FAIL %s is a %s at %s, not a regular file\n' "$f" "$type" "$REV"
|
||||
rc=1; continue
|
||||
fi
|
||||
case "$mode" in
|
||||
100755) printf ' ok %s committed %s\n' "$f" "$mode" ;;
|
||||
120000) printf ' FAIL %s is a SYMLINK (%s) -- the reviewed blob is a path, not the code\n' "$f" "$mode"; rc=1 ;;
|
||||
*) printf ' FAIL %s committed %s, needs 100755\n' "$f" "$mode"
|
||||
printf ' fix with: git update-index --chmod=+x %s && commit\n' "$f"
|
||||
rc=1 ;;
|
||||
esac
|
||||
done
|
||||
if (( rc != 0 )); then
|
||||
printf '\nREFUSING TO CONTINUE: the COMMITTED modes are wrong, whatever the disk says.\n'
|
||||
printf 'A clone of this commit would exit 126 for the next person.\n'
|
||||
exit "$EX_FAIL"
|
||||
fi
|
||||
|
||||
# --- 2. RUN FROM A CLONE OF THAT COMMIT --------------------------------------
|
||||
# Cloning materialises every file from the object store, so the modes on disk
|
||||
# are the COMMITTED modes by construction. No cp, no local state, nothing this
|
||||
# machine can contribute.
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
printf '\n=== cloning %s (no local mode bits survive this) ===\n' "$REV_SHA"
|
||||
git clone -q --no-local --no-hardlinks "$ROOT" "$WORK/clone"
|
||||
git -C "$WORK/clone" -c advice.detachedHead=false checkout -q "$REV_SHA"
|
||||
|
||||
for f in "${ARTIFACTS[@]}"; do
|
||||
printf ' clone disk mode: %s %s\n' "$(stat -c '%a' "$WORK/clone/${PREFIX}${f}")" "${PREFIX}${f}"
|
||||
done
|
||||
|
||||
printf '\n=== executing DIRECTLY (./%s), not via "bash %s" ===\n' "$SUITE" "$SUITE"
|
||||
# Invoking the interpreter explicitly masks a missing exec bit completely -- it
|
||||
# is how the original green was obtained. Direct execution is the thing under
|
||||
# test, so the mode has to be real for this line to succeed.
|
||||
cd "$WORK/clone/${PREFIX}"
|
||||
if ! "./$SUITE"; then
|
||||
printf '\nCLEAN-CLONE RUN FAILED.\n'
|
||||
exit "$EX_FAIL"
|
||||
fi
|
||||
|
||||
printf '\n=== CLEAN CLONE: the COMMITTED artifact ran and passed ===\n'
|
||||
@@ -37,7 +37,7 @@ response=$(curl -sk -w "\n%{http_code}" \
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
if [[ "$http_code" != "200" && "$http_code" != "206" ]]; then
|
||||
echo "Error: Failed to list computers (HTTP $http_code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #807: ranged GLPI list requests may return HTTP 206.
|
||||
#
|
||||
# Each shipped list wrapper must render a healthy 206 response and must retain
|
||||
# its non-zero error behavior for a genuine HTTP failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/glpi-list-http-status}"
|
||||
TOOL_DIR="$WORK_DIR/tools/glpi"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$TOOL_DIR" "$BIN_DIR" "$WORK_DIR/tools/_lib"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
for wrapper in ticket-list.sh computer-list.sh user-list.sh; do
|
||||
cp "$SCRIPT_DIR/$wrapper" "$TOOL_DIR/$wrapper"
|
||||
done
|
||||
|
||||
cat > "$WORK_DIR/tools/_lib/credentials.sh" <<'SH'
|
||||
load_credentials() {
|
||||
export GLPI_URL="https://glpi.test/apirest.php"
|
||||
export GLPI_APP_TOKEN="test-app-token"
|
||||
}
|
||||
SH
|
||||
|
||||
cat > "$TOOL_DIR/session-init.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
printf '%s\n' 'test-session-token'
|
||||
SH
|
||||
chmod +x "$TOOL_DIR/session-init.sh"
|
||||
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' '[{"id":42,"priority":3,"status":1,"name":"Regression fixture","date_mod":"2026-07-16 10:00:00","serial":"SER-42","states_id":1,"realname":"Fixture","firstname":"GLPI","is_active":1}]'
|
||||
printf '%s\n' "${GLPI_TEST_HTTP_CODE:?GLPI_TEST_HTTP_CODE is required}"
|
||||
SH
|
||||
chmod +x "$BIN_DIR/curl"
|
||||
|
||||
export MOSAIC_HOME="$WORK_DIR"
|
||||
export PATH="$BIN_DIR:$PATH"
|
||||
|
||||
wrappers=(ticket-list.sh computer-list.sh user-list.sh)
|
||||
resources=(tickets computers users)
|
||||
headings=(PRIORITY SERIAL USERNAME)
|
||||
fail=0
|
||||
|
||||
for index in "${!wrappers[@]}"; do
|
||||
wrapper="${wrappers[$index]}"
|
||||
resource="${resources[$index]}"
|
||||
heading="${headings[$index]}"
|
||||
path="$TOOL_DIR/$wrapper"
|
||||
|
||||
if ! output=$(GLPI_TEST_HTTP_CODE=206 bash "$path" 2>&1); then
|
||||
echo "FAIL: $wrapper rejected healthy HTTP 206" >&2
|
||||
fail=1
|
||||
elif [[ "$output" != *"$heading"* || "$output" != *"Regression fixture"* ]]; then
|
||||
echo "FAIL: $wrapper did not render the HTTP 206 list response" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "PASS: $wrapper renders HTTP 206"
|
||||
fi
|
||||
|
||||
rc=0
|
||||
output=$(GLPI_TEST_HTTP_CODE=401 bash "$path" 2>&1) || rc=$?
|
||||
if [[ "$rc" -eq 0 ]]; then
|
||||
echo "FAIL: $wrapper accepted HTTP 401" >&2
|
||||
fail=1
|
||||
elif [[ "$output" != *"Error: Failed to list $resource (HTTP 401)"* ]]; then
|
||||
echo "FAIL: $wrapper changed the HTTP failure diagnostic" >&2
|
||||
fail=1
|
||||
else
|
||||
echo "PASS: $wrapper rejects HTTP 401"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "ALL PASS: test-list-http-status.sh"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user