Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions .github/workflows/e2e-harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ on:
permissions:
contents: read

env:
TRUFFLEHOG_IMAGE: ghcr.io/trufflesecurity/trufflehog:3.96.0

concurrency:
group: e2e-harness-${{ github.event.pull_request.number || github.ref }}-${{ github.sha }}
cancel-in-progress: false
Expand Down Expand Up @@ -50,8 +53,31 @@ jobs:
POWERCONTEXT_E2E_OUTPUT: ${{ github.workspace }}/.powercontext-e2e/${{ matrix.database }}/acceptance
run: make harness-compose-acceptance

- name: Upload replay evidence
- name: Scan replay evidence
id: evidence_scan
if: always()
continue-on-error: true
env:
EVIDENCE_PATH: ${{ github.workspace }}/.powercontext-e2e/${{ matrix.database }}/acceptance
run: |
test -d "${EVIDENCE_PATH}"
docker run --rm \
--volume "${EVIDENCE_PATH}:/evidence:ro" \
"${TRUFFLEHOG_IMAGE}" \
filesystem /evidence \
--no-verification \
--results=verified,unknown,unverified \
--fail \
--fail-on-scan-errors \
--no-update \
--json > "${RUNNER_TEMP}/trufflehog-acceptance-${{ matrix.database }}.jsonl"

- name: Report suppressed replay evidence
if: always() && steps.evidence_scan.outcome != 'success'
run: echo "::warning::Replay evidence was not published because secret scanning did not complete cleanly."

- name: Upload replay evidence
if: always() && steps.evidence_scan.outcome == 'success'
uses: actions/upload-artifact@v7
with:
name: e2e-acceptance-${{ matrix.database }}-${{ github.sha }}
Expand Down Expand Up @@ -102,12 +128,44 @@ jobs:
POWERCONTEXT_E2E_OUTPUT: ${{ github.workspace }}/.powercontext-e2e/${{ matrix.database }}/live
run: make harness-compose-live

- name: Publish replay summary
- name: Scan replay evidence
id: evidence_scan
if: always() && steps.provider.outputs.configured == 'true'
continue-on-error: true
env:
EVIDENCE_PATH: ${{ github.workspace }}/.powercontext-e2e/${{ matrix.database }}/live
run: |
test -d "${EVIDENCE_PATH}"
docker run --rm \
--volume "${EVIDENCE_PATH}:/evidence:ro" \
"${TRUFFLEHOG_IMAGE}" \
filesystem /evidence \
--no-verification \
--results=verified,unknown,unverified \
--fail \
--fail-on-scan-errors \
--no-update \
--json > "${RUNNER_TEMP}/trufflehog-live-${{ matrix.database }}.jsonl"

- name: Report suppressed replay evidence
if: >-
always() &&
steps.provider.outputs.configured == 'true' &&
steps.evidence_scan.outcome != 'success'
run: echo "::warning::Replay evidence was not published because secret scanning did not complete cleanly."

- name: Publish replay summary
if: >-
always() &&
steps.provider.outputs.configured == 'true' &&
steps.evidence_scan.outcome == 'success'
run: cat ".powercontext-e2e/${{ matrix.database }}/live/report.md" >> "${GITHUB_STEP_SUMMARY}"

- name: Upload replay evidence
if: always() && steps.provider.outputs.configured == 'true'
if: >-
always() &&
steps.provider.outputs.configured == 'true' &&
steps.evidence_scan.outcome == 'success'
uses: actions/upload-artifact@v7
with:
name: e2e-live-${{ matrix.database }}-${{ github.sha }}
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ harness-check: ## Validate the Bub replay harness and committed scenarios.
@uv run ruff check e2e/bub
@uv run ruff format --check e2e/bub
@uv run ty check --project e2e/bub --python e2e/bub/.venv e2e/bub/src integrations/bub/src
@uv run --project e2e/bub python -m pytest e2e/bub/tests
@uv run --project e2e/bub powercontext-e2e --help >/dev/null

.PHONY: harness-acceptance
Expand Down
9 changes: 6 additions & 3 deletions e2e/bub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ It supports three evidence modes:
- `rescore` reads `replay.json` and runs the same Pydantic Evals oracle without rerunning Bub or PowerContext.

Each run writes `replay.json`, `eval-report.json`, and `report.md`. The replay is self-contained and contains the
scenario, public Memory snapshots, prepared context, outputs, and Pydantic Evals-compatible spans. It never records
API keys, authorization headers, or database URLs.
scenario, public Memory snapshots, prepared context, outputs, and Pydantic Evals-compatible spans. Known runtime
secrets are redacted when these files are written. CI also scans the complete evidence directory with TruffleHog
before publishing a summary or artifact; evidence is not published when that scan does not complete cleanly. Treat
local evidence as potentially sensitive until it has been inspected.

## Run against an existing Server

Expand Down Expand Up @@ -51,4 +53,5 @@ POWERCONTEXT_E2E_DATABASE=oceanbase make harness-compose-acceptance
```

`make harness-compose-live` uses the provider variables above. Evidence is written below `.powercontext-e2e/bub/`;
set `POWERCONTEXT_E2E_OUTPUT` to keep it elsewhere. `make harness-compose-down` removes containers and database volumes.
set `POWERCONTEXT_E2E_OUTPUT` to keep it elsewhere. Compose containers, networks, and volumes are removed after both
successful and failed runs. `make harness-compose-down` remains available as an idempotent manual cleanup.
6 changes: 6 additions & 0 deletions e2e/bub/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ dependencies = [
"pyyaml>=6,<7",
]

[dependency-groups]
dev = ["pytest>=9.0.2"]

[project.scripts]
powercontext-e2e = "powercontext_e2e.__main__:main"

Expand All @@ -38,5 +41,8 @@ line-length = 120
select = ["A", "B", "C4", "C90", "E", "F", "I", "PGH", "RUF", "S", "SIM", "T10", "TRY", "UP", "W", "YTT"]
ignore = ["E501"]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]

[tool.ruff.format]
preview = true
22 changes: 20 additions & 2 deletions e2e/bub/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ if [ -z "${GITHUB_SHA:-}" ]; then
export GITHUB_SHA
fi

cleanup() {
status=$?
trap - EXIT INT TERM
set +e

docker compose $compose_files down --volumes --remove-orphans
cleanup_status=$?
if [ "$cleanup_status" -ne 0 ]; then
echo "Compose cleanup failed with exit code $cleanup_status" >&2
if [ "$status" -eq 0 ]; then
status=$cleanup_status
fi
fi
exit "$status"
}

trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

docker compose $compose_files build powercontext harness
docker compose $compose_files up --detach --wait powercontext

Expand All @@ -81,5 +101,3 @@ else
scenario=${POWERCONTEXT_E2E_SCENARIO:-e2e/bub/scenarios/project-database-decision.yaml}
docker compose $compose_files run --rm harness live "$scenario" --output /evidence
fi

docker compose $compose_files down --volumes --remove-orphans
54 changes: 38 additions & 16 deletions e2e/bub/src/powercontext_e2e/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@
Mode = Literal["acceptance", "live", "offline-rescore"]
Report = EvaluationReport[ScenarioSpec, ReplayObservation, dict[str, str]]
Context = EvaluatorContext[ScenarioSpec, ReplayObservation, dict[str, str]]
_EVIDENCE_SECRET_ENVIRONMENT_NAMES = (
"ANTHROPIC_API_KEY",
"BUB_API_KEY",
"DEEPSEEK_API_KEY",
"OPENAI_API_KEY",
"OPENROUTER_API_KEY",
"POWERCONTEXT_CLIENT_API_TOKEN",
"POWERCONTEXT_SERVER_AUTH_TOKEN",
"POWERCONTEXT_SERVER_DATABASE_URL",
)
Comment thread
thunguo marked this conversation as resolved.
_REDACTED = "[REDACTED]"


@dataclass
Expand Down Expand Up @@ -223,7 +234,7 @@ async def _run_replay(
except Exception as exc:
output = ""
status = "failed"
error = _redact(f"{type(exc).__name__}: {exc}")
error = f"{type(exc).__name__}: {exc}"
errors.append(f"Session {session.id}: {error}")
Comment thread
thunguo marked this conversation as resolved.
memory_after_session = await _memory_snapshot(client, scope_id)
observations.append(
Expand All @@ -233,15 +244,15 @@ async def _run_replay(
status=status,
error=error,
prepared_context=prepared,
output=_redact(output),
output=output,
memory_after=memory_after_session,
)
)
if status == "failed":
break
memory_after = await _memory_snapshot(client, scope_id)
except Exception as exc:
errors.append(_redact(f"{type(exc).__name__}: {exc}"))
errors.append(f"{type(exc).__name__}: {exc}")
memory_after = observations[-1].memory_after if observations else memory_before

return ReplayObservation(
Expand Down Expand Up @@ -390,16 +401,14 @@ def _commit() -> str:
return completed.stdout.strip() if completed.returncode == 0 else "unknown"


def _redact(value: str) -> str:
secret = os.getenv("BUB_API_KEY")
return value.replace(secret, "[REDACTED]") if secret else value


def write_artifacts(observation: ReplayObservation, report: Report, output_dir: Path) -> None:
secrets = _runtime_secrets()
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "replay.json").write_text(
observation.model_dump_json(by_alias=True, indent=2) + "\n",
encoding="utf-8",
replay_payload = observation.model_dump(mode="json", by_alias=True)
_write_evidence(
output_dir / "replay.json",
json.dumps(replay_payload, indent=2, ensure_ascii=False) + "\n",
secrets,
)

cases = [
Expand Down Expand Up @@ -428,17 +437,30 @@ def write_artifacts(observation: ReplayObservation, report: Report, output_dir:
"cases": cases,
"failures": [{"name": failure.name, "error": failure.error_message} for failure in report.failures],
}
(output_dir / "eval-report.json").write_text(
json.dumps(report_payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
_write_evidence(
output_dir / "eval-report.json",
json.dumps(report_payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
secrets,
)
(output_dir / "report.md").write_text(
_write_evidence(
output_dir / "report.md",
"# PowerContext session replay\n\n"
f"- Scenario: `{observation.scenario.id}`\n"
f"- Mode: `{observation.environment.mode}`\n"
f"- Database: `{observation.environment.database}`\n"
f"- Status: `{observation.status}`\n\n"
"## Evaluation\n\n"
f"```text\n{report.render(include_reasons=True)}\n```\n",
encoding="utf-8",
secrets,
)


def _runtime_secrets() -> tuple[str, ...]:
secrets = {value for name in _EVIDENCE_SECRET_ENVIRONMENT_NAMES if (value := os.getenv(name))}
return tuple(sorted(secrets, key=lambda value: (-len(value), value)))


def _write_evidence(path: Path, content: str, secrets: tuple[str, ...]) -> None:
for secret in secrets:
content = content.replace(secret, _REDACTED)
path.write_text(content, encoding="utf-8")
Comment thread
thunguo marked this conversation as resolved.
Loading