diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66ff58056..8b20741a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,41 @@ concurrency: cancel-in-progress: true jobs: + compose_runtime: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Start and probe Compose infrastructure + shell: bash + run: | + set -Eeuo pipefail + cleanup() { + status=$? + trap - EXIT + if [ "$status" -ne 0 ]; then + docker compose ps --all || true + docker compose logs --no-color --timestamps --tail 200 postgres nats || true + fi + docker compose down --volumes --remove-orphans || true + exit "$status" + } + trap cleanup EXIT + + docker compose up --detach --wait --wait-timeout 90 + docker compose exec --no-TTY postgres psql -U lifeos -d lifeos -v ON_ERROR_STOP=1 -tAc 'SELECT 1' | + grep -Fx 1 + curl --fail --silent --show-error --max-time 5 \ + http://127.0.0.1:8222/jsz | + jq -e '(.streams | type) == "number" and (.consumers | type) == "number"' \ + >/dev/null + validate: + needs: compose_runtime runs-on: ubuntu-latest timeout-minutes: 20 env: diff --git a/.github/workflows/opencode-commercial-development.yml b/.github/workflows/opencode-commercial-development.yml index f6f563d4b..3e85ef2d0 100644 --- a/.github/workflows/opencode-commercial-development.yml +++ b/.github/workflows/opencode-commercial-development.yml @@ -242,20 +242,44 @@ jobs: COREPACK_HOME="$trusted_corepack_home" \ corepack install --global "$package_manager" chmod -R u=rwX,go=rX "$trusted_corepack_home" - MODEL_HOME="$model_home" NIM_BRIDGE_PORT="$NIM_BRIDGE_PORT" python3 - <<'PYCONFIG' + MODEL_HOME="$model_home" MODEL_WORKSPACE="$model_workspace" NIM_BRIDGE_PORT="$NIM_BRIDGE_PORT" RECEIPT_DIR="$RECEIPT_DIR" python3 - <<'PYCONFIG' import json import os from pathlib import Path + run = json.loads( + (Path(os.environ['RECEIPT_DIR']) / 'run.json').read_text(encoding='utf-8') + ) + model_label = run['model_label'] + provider_id, separator, model_id = model_label.partition('/') + if provider_id != 'nvidia' or separator != '/' or not model_id: + raise SystemExit('invalid_nvidia_model_label') + model_workspace_path = Path(os.environ['MODEL_WORKSPACE']) + instruction_files = [ + model_workspace_path / 'AGENTS.md', + model_workspace_path / 'CLAUDE.md', + ] + if any(not path.is_file() for path in instruction_files): + raise SystemExit('missing_reviewed_model_instruction') + instruction_paths = [str(path) for path in instruction_files] + config = { '$schema': 'https://opencode.ai/config.json', 'autoupdate': False, 'share': 'disabled', + 'enabled_providers': ['nvidia'], + 'model': model_label, + 'small_model': model_label, + 'instructions': instruction_paths, 'provider': { 'nvidia': { + 'whitelist': [model_id], + 'models': {model_id: {'name': model_id}}, 'options': { 'baseURL': f"http://127.0.0.1:{os.environ['NIM_BRIDGE_PORT']}/v1", 'apiKey': 'local-loopback-placeholder', + 'timeout': 60_000, + 'chunkTimeout': 30_000, }, }, }, @@ -305,9 +329,28 @@ jobs: echo 'MODEL_NETWORK_PHASE=model' } >> "$GITHUB_ENV" + - name: Validate the explicit OpenCode model catalog + id: model_catalog + if: steps.branch.outcome == 'success' + run: | + set -Eeuo pipefail + OPENCODE_MODEL="$(jq -r '.model_label' "$RECEIPT_DIR/run.json")" + sudo -u opencode_model env -i \ + HOME="$MODEL_HOME" \ + PATH="$PATH" \ + COREPACK_HOME="$TRUSTED_COREPACK_HOME" \ + COREPACK_ENABLE_NETWORK=0 \ + OPENCODE_CONFIG="$MODEL_HOME/opencode.json" \ + OPENCODE_DISABLE_AUTOUPDATE=true \ + OPENCODE_DISABLE_MODELS_FETCH=true \ + OPENCODE_DISABLE_PROJECT_CONFIG=true \ + NVIDIA_API_KEY=local-loopback-placeholder \ + bash -c 'cd "$1" && catalog="$(pnpm --filter @life-os/commercial-development-agent exec opencode models nvidia)" && test "$catalog" = "$2"' \ + _ "$MODEL_WORKSPACE" "$OPENCODE_MODEL" + - name: Start loopback NVIDIA credential bridge id: bridge - if: steps.branch.outcome == 'success' + if: steps.model_catalog.outcome == 'success' env: NVIDIA_NIM_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} run: | @@ -451,6 +494,8 @@ jobs: COREPACK_ENABLE_NETWORK=0 \ OPENCODE_CONFIG="$MODEL_HOME/opencode.json" \ OPENCODE_DISABLE_AUTOUPDATE=true \ + OPENCODE_DISABLE_MODELS_FETCH=true \ + OPENCODE_DISABLE_PROJECT_CONFIG=true \ NVIDIA_API_KEY=local-loopback-placeholder \ bash -c 'cd "$1" && timeout --signal=TERM --kill-after=30s 90m pnpm --filter @life-os/commercial-development-agent exec opencode run --pure --auto --model "$2" --format json --file .opencode-task.md "Execute the attached policy-isolated LifeOS task."' \ _ "$MODEL_WORKSPACE" "$OPENCODE_MODEL" \ @@ -692,7 +737,8 @@ jobs: id: verification if: steps.diff.outputs.accepted == 'true' run: | - set +e + set -Eeuo pipefail + status=0 sudo -u opencode_model env -i \ HOME="$MODEL_HOME" \ PATH="$PATH" \ @@ -705,12 +751,10 @@ jobs: HABIT_DATABASE_URL="$HABIT_DATABASE_URL" \ NOTIFICATION_DATABASE_URL="$NOTIFICATION_DATABASE_URL" \ PRIVACY_DATABASE_URL="$PRIVACY_DATABASE_URL" \ - bash -c 'cd "$1" && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build && docker compose config >/dev/null' \ + bash -c 'cd "$1" && pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm build' \ _ "$MODEL_WORKSPACE" \ - > "$RECEIPT_DIR/verification.log" 2>&1 - status=$? + > "$RECEIPT_DIR/verification.log" 2>&1 || status=$? sudo pkill --signal TERM --euid opencode_model 2>/dev/null || true - set -e chmod 0600 "$RECEIPT_DIR/verification.log" if [ "$status" -eq 0 ]; then echo 'passed=true' >> "$GITHUB_OUTPUT" @@ -718,8 +762,26 @@ jobs: echo 'passed=false' >> "$GITHUB_OUTPUT" fi - - name: Materialize verified candidate through trusted boundary + - name: Validate Compose configuration through trusted boundary + id: compose_config if: steps.verification.outputs.passed == 'true' + run: | + set -Eeuo pipefail + status=0 + docker compose \ + --file "$MODEL_WORKSPACE/compose.yaml" \ + --project-directory "$MODEL_WORKSPACE" \ + config --quiet \ + > "$RECEIPT_DIR/compose-validation.log" 2>&1 || status=$? + chmod 0600 "$RECEIPT_DIR/compose-validation.log" + if [ "$status" -eq 0 ]; then + echo 'passed=true' >> "$GITHUB_OUTPUT" + else + echo 'passed=false' >> "$GITHUB_OUTPUT" + fi + + - name: Materialize verified candidate through trusted boundary + if: steps.compose_config.outputs.passed == 'true' run: | set -Eeuo pipefail python3 - <<'PYMATERIALIZE' @@ -801,7 +863,7 @@ jobs: - name: Recheck the exact main base before remote mutation id: base - if: steps.verification.outputs.passed == 'true' + if: steps.compose_config.outputs.passed == 'true' run: | set -Eeuo pipefail current="$(timeout 30s git ls-remote origin refs/heads/main | awk 'NR == 1 {print $1}')" @@ -876,10 +938,12 @@ jobs: env: OPEN_PULL_REQUESTS: ${{ steps.github_evidence.outputs.open_pull_requests }} SELECTED: ${{ steps.selection.outputs.selected }} + MODEL_CATALOG_OUTCOME: ${{ steps.model_catalog.outcome }} BRIDGE_REASON: ${{ steps.bridge.outputs.reason }} MODEL_REASON: ${{ steps.model.outputs.reason }} DIFF_ACCEPTED: ${{ steps.diff.outputs.accepted }} VERIFICATION_PASSED: ${{ steps.verification.outputs.passed }} + COMPOSE_CONFIG_PASSED: ${{ steps.compose_config.outputs.passed }} BASE_MATCHED: ${{ steps.base.outputs.matched }} PR_CREATED: ${{ steps.mutation.outputs.created }} BRANCH_NAME: ${{ steps.branch.outputs.branch_name }} @@ -916,15 +980,20 @@ jobs: open_prs = os.environ.get('OPEN_PULL_REQUESTS', '0') selected = os.environ.get('SELECTED', 'false') == 'true' + model_catalog_outcome = os.environ.get('MODEL_CATALOG_OUTCOME', '') bridge_reason = os.environ.get('BRIDGE_REASON', '') model_reason = os.environ.get('MODEL_REASON', '') diff_accepted = os.environ.get('DIFF_ACCEPTED', '') == 'true' - verification = os.environ.get('VERIFICATION_PASSED', '') == 'true' + source_verification = os.environ.get('VERIFICATION_PASSED', '') == 'true' + compose_configuration = os.environ.get('COMPOSE_CONFIG_PASSED', '') == 'true' + verification = source_verification and compose_configuration base_matched = os.environ.get('BASE_MATCHED', '') == 'true' pr_created = os.environ.get('PR_CREATED', '') == 'true' if open_prs != '0' or not selected: status, reason = 'unavailable', 'no_eligible_issue' + elif model_catalog_outcome != 'success': + status, reason = 'failed', 'invalid_configuration' elif bridge_reason == 'provider_credential_missing': status, reason = 'unavailable', 'provider_credential_missing' elif bridge_reason != 'completed' or model_reason != 'completed': @@ -943,10 +1012,12 @@ jobs: validations = [ {'name': 'open_pull_request_drain', 'status': 'passed' if open_prs == '0' else 'failed'}, {'name': 'issue_policy', 'status': 'passed' if selected else 'skipped'}, - {'name': 'credential_bridge', 'status': 'skipped' if not selected else 'passed' if bridge_reason == 'completed' else 'failed'}, - {'name': 'provider_run', 'status': 'skipped' if not selected or bridge_reason != 'completed' else 'passed' if model_reason == 'completed' else 'failed'}, - {'name': 'diff_policy', 'status': 'passed' if diff_accepted else 'skipped' if not selected else 'failed'}, - {'name': 'repository_verification', 'status': 'passed' if verification else 'skipped' if not diff_accepted else 'failed'}, + {'name': 'model_catalog', 'status': 'skipped' if not selected or open_prs != '0' else 'passed' if model_catalog_outcome == 'success' else 'failed'}, + {'name': 'credential_bridge', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' else 'passed' if bridge_reason == 'completed' else 'failed'}, + {'name': 'provider_run', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' or bridge_reason != 'completed' else 'passed' if model_reason == 'completed' else 'failed'}, + {'name': 'diff_policy', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' or bridge_reason != 'completed' or model_reason != 'completed' else 'passed' if diff_accepted else 'failed'}, + {'name': 'repository_verification', 'status': 'passed' if source_verification else 'skipped' if not diff_accepted else 'failed'}, + {'name': 'compose_configuration', 'status': 'passed' if compose_configuration else 'skipped' if not source_verification else 'failed'}, {'name': 'base_sha', 'status': 'passed' if base_matched else 'skipped' if not verification else 'failed'}, ] input_value = { @@ -1012,6 +1083,7 @@ jobs: "$RECEIPT_DIR/prompt.txt" \ "$RECEIPT_DIR/opencode.log" \ "$RECEIPT_DIR/verification.log" \ + "$RECEIPT_DIR/compose-validation.log" \ "$RECEIPT_DIR/nim-bridge.py" \ "$RECEIPT_DIR/issues.json" \ "$RECEIPT_DIR/pulls.json" \ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e4c9a0543..1b09f7e4c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -113,6 +113,8 @@ Pull requests follow one loop: inspect every review and check, fix root causes, Scheduled model-assisted automation uses `NVIDIA_NIM_API_KEY`; `COPILOT_GITHUB_TOKEN` is prohibited. Existing dedicated review-agent credentials are not repurposed. Deterministic audit and merge eligibility remain independently enforceable even when a model provider is unavailable. +The pinned OpenCode configuration disables project-local overrides, explicitly reloads reviewed repository instructions, enables only NVIDIA, registers and whitelists one model label independently of the bundled catalog, pins primary and small-model work to it, and checks that effective catalog offline before its credential bridge starts; the bridge exposes no provider-wide discovery route. Model-generated source verification runs without Docker authority. A later trusted operation parses the accepted candidate's explicitly selected Compose file, while credential-free pull-request CI starts digest-pinned images, proves PostgreSQL query execution and NATS JetStream availability, binds published ports to loopback, and tears down unconditionally. + ## 6. Documentation hierarchy 1. `AGENTS.md` — repository-wide agent and merge rules. diff --git a/CHANGELOG.md b/CHANGELOG.md index a6982da2d..4120874ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to LifeOS are documented in this file. ### Fixed +- The OpenCode development loop now prevents project settings from overriding its pinned offline NVIDIA model, records catalog failures accurately, parses the accepted candidate's exact Compose file outside the model account, and requires digest-pinned PostgreSQL queries plus NATS JetStream probes in pull-request CI. - Live contextual-orchestrator responses now classify successful empty bodies as evaluation failures, emit exactly one terminal observation, canonicalize retained timestamps safely, and preserve null metric denominators instead of fabricating deltas. - Stale AI proposal revision conflicts now belong to the technology-independent audit domain while the PostgreSQL adapter preserves its compatibility export. - Planning search now normalizes browser query text and prevents stale or unmounted requests from replacing the latest visible result state. @@ -32,6 +33,7 @@ All notable changes to LifeOS are documented in this file. ### Security +- The commercial-development model account no longer performs Docker commands, never receives Docker-socket authority, and cannot trigger provider-wide model discovery through the credential bridge. - The scheduled live-model harness uses only `NVIDIA_NIM_API_KEY`, seeds it through the encrypted contextual-orchestrator credential registry, installs hash-locked dependencies from an exact commit, confines LifeOS traffic to loopback, allowlists NVIDIA NIM egress, and excludes provider credentials, prompts, responses, traces, and hidden reasoning from retained artifacts. - Proposal quality reports now discard nested model failures and response bodies, normalize labeled sentinel checks, expose no provider credential or mutation dependency, and measure prompt-injection resistance together with benign utility instead of rewarding blanket refusal. - External proposal generation now accepts only one credential-free HTTPS orchestrator origin, stops responses at 65536 bytes, enforces a bounded abort timeout, supplies no tools, treats planning context as untrusted data, and exposes only sanitized failures. diff --git a/compose.yaml b/compose.yaml index 9f3222b80..7c44ce4fb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,12 +1,12 @@ services: postgres: - image: postgres:17-alpine + image: postgres:17.10-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 environment: POSTGRES_USER: lifeos POSTGRES_PASSWORD: lifeos POSTGRES_DB: lifeos ports: - - '5432:5432' + - '127.0.0.1:5432:5432' volumes: - lifeos-postgres:/var/lib/postgresql/data healthcheck: @@ -16,11 +16,11 @@ services: retries: 10 nats: - image: nats:2.11-alpine + image: nats:2.11.6-alpine@sha256:e4bf19f15fd3218814a4e3c9e0064e1334bd8aa20d5984b9f1a0afd084f8cc00 command: ['-js', '-m', '8222'] ports: - - '4222:4222' - - '8222:8222' + - '127.0.0.1:4222:4222' + - '127.0.0.1:8222:8222' volumes: - lifeos-nats:/data healthcheck: diff --git a/docs/operations/opencode-commercial-development-loop.md b/docs/operations/opencode-commercial-development-loop.md index 67c41b659..213caaac0 100644 --- a/docs/operations/opencode-commercial-development-loop.md +++ b/docs/operations/opencode-commercial-development-loop.md @@ -7,7 +7,7 @@ The `OpenCode Commercial Development` workflow may implement one explicitly elig ## Enablement prerequisites 1. Store the NVIDIA provider credential as the repository secret `NVIDIA_NIM_API_KEY`. -2. Optionally configure repository variable `OPENCODE_NVIDIA_MODEL` with one NVIDIA NIM chat model identifier. When the value omits the provider prefix, the workflow prefixes `nvidia/`. +2. Optionally configure repository variable `OPENCODE_NVIDIA_MODEL` with one reviewed NVIDIA NIM chat model identifier. When the value omits the provider prefix, the workflow prefixes `nvidia/`. The private configuration registers that identifier in `provider.nvidia.models`, whitelists only it, and requires the offline preflight to return exactly that label before the credential bridge starts. 3. Keep `product/opencode-commercial-development-policy.json` under normal pull-request review. 4. Add an issue title to `eligible_issue_titles` only through a reviewed pull request after confirming the issue fits the initial non-destructive write boundary. 5. Verify the exact OpenCode package and `pnpm-lock.yaml` pin after every OpenCode update. @@ -52,6 +52,8 @@ sequenceDiagram During model execution, UID-based `iptables` rules reject other IPv4 and all IPv6 egress from `opencode_model`, permitting only the configured loopback bridge port. The workflow terminates the bridge before repository verification and removes the model and bridge processes, firewall rules, private homes, configuration, prompt, bridge code, and log in the `always()` cleanup step. +OpenCode catalog refresh and project-local configuration discovery are disabled. The private configuration explicitly reloads the reviewed workspace `AGENTS.md` and `CLAUDE.md`, enables only NVIDIA, registers and whitelists exactly the selected model under `provider.nvidia.models`, pins both primary and small-model work to that label, and validates the exact label before the bridge starts. This registration also covers a reviewed NVIDIA model that is absent from OpenCode's bundled snapshot. A failed catalog preflight is retained as `invalid_configuration`; later bridge and provider validations remain skipped. The bridge therefore exposes only `/healthz` and bounded `/v1/chat/completions`; it does not proxy provider-wide model discovery. + The credential must never appear in: - Git configuration; @@ -71,6 +73,10 @@ A suspected credential disclosure requires immediate secret rotation, cancellati The checkout disables persisted credentials. OpenCode receives no `GITHUB_TOKEN` or `GH_TOKEN`. A later deterministic step receives `github.token` only after the diff, repository tests, and exact base SHA pass. That step may create one commit, push one same-repository UUIDv4 branch, and open one draft pull request. It cannot merge or release. +### Docker authority and runtime proof + +The isolated `opencode_model` account runs format, lint, typecheck, test, and build commands but never receives Docker-group membership or Docker-socket access. A separate trusted step passes the accepted candidate's exact `compose.yaml` through `--file` and runs only `docker compose config --quiet`; `--project-directory` supplies its path-resolution base. The resulting draft pull request then enters normal credential-free CI, which starts digest-pinned PostgreSQL and NATS images with `docker compose up --wait`, executes PostgreSQL `SELECT 1`, validates the NATS JetStream `/jsz` response shape, prints only a bounded timestamped log tail on failure, and tears down containers and volumes on every exit. Compose publishes its development ports on loopback only. + ## Policy changes Changes to allowed paths, issue titles, limits, model profiles, credentials, permissions, or mutation authority are security-sensitive architecture changes. They require: @@ -104,11 +110,11 @@ Retention is seven days. The receipt records counts, stable classifications, exa | `prompt_rejected` | Prompt exceeds or violates the fixed contract | None | | `diff_rejected` | Working-tree output violates path, object, size, content, or no-change policy | None | | `base_changed` | `main` advanced after the run began | None | -| `verification_failed` | Repository tests or build failed | None | +| `verification_failed` | Repository tests, build, or trusted Compose parsing failed | None | | `draft_pull_request_failed` | A validated commit could not become a draft PR | Possible unreferenced automation branch; reconcile immediately | | `completed` | One draft PR was created; normal review is still required | One branch and one draft PR | -The receipt contract reserves `opencode_unavailable`, `invalid_configuration`, and `prompt_rejected`; the current workflow receipt composer does not emit those codes. +The receipt composer emits `invalid_configuration` when the explicit model-catalog preflight does not succeed. The contract reserves `opencode_unavailable` and `prompt_rejected` for future deterministic classifications that the current workflow does not emit. ## Branch reconciliation @@ -133,7 +139,7 @@ Never force-push an automation branch. If `main` advances, abandon the branch an 2. Resolve the current official `opencode-ai` version once. 3. Add it with an exact version and update `pnpm-lock.yaml`. 4. Verify `opencode --version` and `opencode run --help`. -5. Run package and workflow-contract tests. +5. Run package and workflow-contract tests, including the offline one-model catalog preflight and Compose authority separation. 6. Inspect the lockfile and transitive dependency change. 7. Remove the temporary write-capable bootstrap workflow. 8. Obtain normal exact-head security and review evidence. diff --git a/docs/research/2026-08-07-opencode-commercial-development-loop-standards.md b/docs/research/2026-08-07-opencode-commercial-development-loop-standards.md index e4f81c0ad..a39cdf91d 100644 --- a/docs/research/2026-08-07-opencode-commercial-development-loop-standards.md +++ b/docs/research/2026-08-07-opencode-commercial-development-loop-standards.md @@ -25,10 +25,14 @@ The OWASP Top 10 for LLM Applications identifies prompt injection, sensitive-inf ## OpenCode and NVIDIA provider boundary -OpenCode exposes a non-interactive `run` command and provider/model configuration. LifeOS uses one exact reviewed `opencode-ai` package version and verifies both the installed version and command contract. Auto-update and sharing are disabled. The model receives a private configuration and a source archive without `.git`; Bash is denied by default except for reviewed `pnpm`, `node`, `python3`, `grep`, `rg`, `find`, `ls`, and `cat` command patterns, while web-fetch, web-search, and external-directory access are denied. The prompt is attached from a private file instead of carrying issue text in process arguments (Anomaly, 2026). +OpenCode exposes a non-interactive `run` command and provider/model configuration. LifeOS uses one exact reviewed `opencode-ai` package version and verifies both the installed version and command contract. Auto-update, sharing, Models.dev refresh, and project-local configuration discovery are disabled; reviewed workspace instruction files are then loaded explicitly. The private configuration enables only NVIDIA, registers the reviewed identifier in `provider.nvidia.models`, whitelists it, pins primary and small-model work to that label, and requires `opencode models nvidia` to return exactly that fully qualified label before the bridge starts. Explicit registration avoids dependence on whether the identifier is present in the binary's bundled snapshot, so no provider `/v1/models` proxy is needed. The model receives a private configuration and a source archive without `.git`; Bash is denied by default except for reviewed `pnpm`, `node`, `python3`, `grep`, `rg`, `find`, `ls`, and `cat` command patterns, while web-fetch, web-search, and external-directory access are denied. The prompt is attached from a private file instead of carrying issue text in process arguments (Anomaly, 2026). NVIDIA NIM exposes hosted OpenAI-compatible inference authenticated with an API key. `NVIDIA_NIM_API_KEY` is mapped only to a loopback bridge running as `opencode_bridge`. OpenCode runs separately as `opencode_model` with a placeholder API-key value and an allowlisted minimal environment; UID-based `iptables` rules restrict its model-phase egress to the bridge. GitHub, review-agent, deployment, and unrelated repository credentials are absent. Provider availability is evidence, not a deterministic merge prerequisite (NVIDIA Corporation, 2026). +### Docker Compose verification boundary + +Docker documents `--file` as the way to select a Compose configuration and `docker compose config` as parsing, resolving, and rendering the resulting application model; `docker compose up --wait` creates services and waits for them to be running or healthy. LifeOS selects the accepted candidate file explicitly and keeps parsing in a trusted step instead of granting Docker authority to `opencode_model`. Actual PostgreSQL query execution, NATS JetStream monitoring, bounded failure logs, and teardown run in ordinary credential-free pull-request CI, where no NVIDIA or GitHub write credential is present, container images are digest-pinned, and published development ports bind only to loopback (Docker, Inc., 2026a, 2026b, 2026c). + ## Test-time compute allocation ### Strong single-agent baseline @@ -74,6 +78,12 @@ Latency and token use are recorded for cost and capacity review but are not the Anomaly. (2026). _OpenCode documentation_. https://opencode.ai/docs/ +Docker, Inc. (2026a). _docker compose_. https://docs.docker.com/reference/cli/docker/compose/ + +Docker, Inc. (2026b). _docker compose config_. https://docs.docker.com/reference/cli/docker/compose/config/ + +Docker, Inc. (2026c). _docker compose up_. https://docs.docker.com/reference/cli/docker/compose/up/ + GitHub. (2026a). _Security hardening for GitHub Actions_. https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions GitHub. (2026b). _Reusing workflows_. https://docs.github.com/en/actions/using-workflows/reusing-workflows diff --git a/docs/superpowers/plans/2026-08-07-opencode-commercial-development-loop.md b/docs/superpowers/plans/2026-08-07-opencode-commercial-development-loop.md index 4efa4c054..b5bd51716 100644 --- a/docs/superpowers/plans/2026-08-07-opencode-commercial-development-loop.md +++ b/docs/superpowers/plans/2026-08-07-opencode-commercial-development-loop.md @@ -194,7 +194,12 @@ Add a workflow-contract test that rejects: - mutable GitHub Action tags; - missing CLI-version verification; - OpenCode invocation with GitHub credentials; -- provider credentials outside the one model step. +- provider credentials outside the one bridge step; +- provider-side `/v1/models` discovery instead of one offline, explicitly whitelisted NVIDIA catalog entry; +- project-local OpenCode configuration that can override the private provider boundary; +- Docker commands executed as the isolated model user; +- Compose parsing that does not select the accepted candidate file explicitly; +- missing pull-request CI startup, health probes, diagnostics, or teardown for the Compose infrastructure. ### GREEN @@ -247,14 +252,16 @@ Workflow sequence: 2. bounded issue evidence collection; 3. issue selection and prompt creation; 4. UUIDv4 branch creation from exact main SHA; -5. loopback bridge invocation with the NVIDIA credential, followed by OpenCode with only a placeholder provider key and no GitHub credential; -6. deterministic diff validation; -7. repository tests selected from changed packages plus root gates; -8. base-SHA recheck; -9. commit and push the bounded branch; -10. create a draft pull request; -11. publish a sanitized receipt; -12. clean temporary files. +5. disable project-local OpenCode configuration, explicitly reload reviewed repository instructions, register and whitelist the selected NVIDIA model independently of the bundled snapshot, and validate the effective catalog offline; +6. loopback bridge invocation with the NVIDIA credential, followed by OpenCode with only a placeholder provider key and no GitHub credential; +7. deterministic diff validation; +8. credential-free repository format, lint, typecheck, test, and build gates under the isolated model account; +9. explicit candidate-file Compose parsing in a separate trusted step without granting the model account Docker-socket authority; +10. base-SHA recheck; +11. commit and push the bounded branch; +12. create a draft pull request whose normal CI boots digest-pinned Compose infrastructure, executes a PostgreSQL query, and validates NATS JetStream before validation can pass; +13. publish a sanitized receipt that distinguishes model-catalog configuration failure from provider unavailability; +14. clean temporary files. Provider absence or failure ends with a successful sanitized unavailable receipt and no branch push. diff --git a/docs/superpowers/specs/2026-08-07-opencode-commercial-development-loop-design.md b/docs/superpowers/specs/2026-08-07-opencode-commercial-development-loop-design.md index 0a8a3d937..0a0429768 100644 --- a/docs/superpowers/specs/2026-08-07-opencode-commercial-development-loop-design.md +++ b/docs/superpowers/specs/2026-08-07-opencode-commercial-development-loop-design.md @@ -135,7 +135,7 @@ Initial hard limits: ## OpenCode provider boundary -The workflow installs one exact OpenCode package version recorded in `pnpm-lock.yaml` and verifies `opencode --version` before use. It does not use an unpinned installer script, mutable action tag, or floating package version. +The workflow installs one exact OpenCode package version recorded in `pnpm-lock.yaml` and verifies `opencode --version` before use. It does not use an unpinned installer script, mutable action tag, or floating package version. Project-local configuration discovery is disabled, reviewed workspace instructions are loaded explicitly, and the private provider configuration enables only NVIDIA, registers the selected identifier in `provider.nvidia.models`, pins primary and small-model work to that label, whitelists exactly that model, disables catalog refresh, and requires `opencode models nvidia` to return the one fully qualified label before any provider credential bridge starts. Explicit registration removes dependence on the binary's bundled Models.dev snapshot. The bridge therefore needs no `/v1/models` route; the subsequent bounded completion request remains the live provider/model availability check. Catalog failure is classified as `invalid_configuration`, not provider failure. A loopback NVIDIA credential bridge runs as `opencode_bridge` and alone receives `secrets.NVIDIA_NIM_API_KEY`. It forwards bounded chat-completion requests to NVIDIA NIM. @@ -151,6 +151,8 @@ UID-based `iptables` rules deny other model-process egress during model executio The process does not receive `GITHUB_TOKEN`, `GH_TOKEN`, `COPILOT_GITHUB_TOKEN`, browser credentials, review-agent secrets, deployment credentials, or unrelated repository secrets. Provider absence or outage produces `provider_unavailable`; it does not make the deterministic audit or merge drain fail. +Repository format, lint, typecheck, test, and build commands run as `opencode_model` without Docker-socket authority. After those commands pass, a separate trusted step selects the already diff-accepted candidate's exact Compose file with `--file`, sets its path-resolution base with `--project-directory`, and runs `docker compose config --quiet`. The model user is never added to the Docker group. Normal pull-request CI separately starts digest-pinned PostgreSQL and NATS Compose services with `docker compose up --wait`, proves PostgreSQL query execution with `SELECT 1`, validates the NATS JetStream `/jsz` response, emits bounded diagnostics on failure, and always removes containers and volumes. Published development ports bind only to loopback (Docker, Inc., 2026a, 2026b, 2026c). + ## Test-time compute policy A strong single-model OpenCode route is the mandatory baseline. The initial implementation profile uses one model and one bounded run. The package reserves explicit versioned fields for: @@ -246,7 +248,9 @@ Deterministic tests cover: - workflow, branch-protection, release, deployment, and destructive-operation attempts; - a realistic buyer-gap fixture that produces a bounded application/test/documentation diff; - provider missing/outage behavior; +- exact offline NVIDIA catalog selection without provider-side model discovery; - exact package/action pins and workflow permission separation; +- Compose parsing outside the isolated model account plus credential-free runtime health probes; - draft-only pull-request creation; - credential-free receipt serialization; - route-versus-orchestration ablation arithmetic without live-provider dependence. @@ -276,6 +280,12 @@ Fugu reports dynamic selection between direct and coordinated expert solutions ( Anomaly. (2026). _OpenCode documentation_. https://opencode.ai/docs/ +Docker, Inc. (2026a). _docker compose_. https://docs.docker.com/reference/cli/docker/compose/ + +Docker, Inc. (2026b). _docker compose config_. https://docs.docker.com/reference/cli/docker/compose/config/ + +Docker, Inc. (2026c). _docker compose up_. https://docs.docker.com/reference/cli/docker/compose/up/ + GitHub. (2026a). _Security hardening for GitHub Actions_. https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions GitHub. (2026b). _Reuse workflows_. https://docs.github.com/en/actions/using-workflows/reusing-workflows diff --git a/packages/commercial-development-agent/src/workflow-contract.test.mjs b/packages/commercial-development-agent/src/workflow-contract.test.mjs index 5e124fd95..dc6d98dff 100644 --- a/packages/commercial-development-agent/src/workflow-contract.test.mjs +++ b/packages/commercial-development-agent/src/workflow-contract.test.mjs @@ -1,22 +1,58 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; import { describe, expect, it } from 'vitest'; const WORKFLOW_PATH = resolve( import.meta.dirname, '../../../.github/workflows/opencode-commercial-development.yml', ); +const CI_WORKFLOW_PATH = resolve( + import.meta.dirname, + '../../../.github/workflows/ci.yml', +); +const COMPOSE_PATH = resolve(import.meta.dirname, '../../../compose.yaml'); const PACKAGE_PATH = resolve(import.meta.dirname, '../package.json'); const workflow = readFileSync(WORKFLOW_PATH, 'utf8'); +const ciWorkflow = readFileSync(CI_WORKFLOW_PATH, 'utf8'); +const compose = readFileSync(COMPOSE_PATH, 'utf8'); const packageJson = JSON.parse(readFileSync(PACKAGE_PATH, 'utf8')); +const linuxX64Test = + process.platform === 'linux' && process.arch === 'x64' ? it : it.skip; /** Returns one named workflow step including its body but not the next step. */ -function step(name) { +function namedStep(source, name) { const marker = ` - name: ${name}\n`; - const start = workflow.indexOf(marker); + const start = source.indexOf(marker); + expect(start).toBeGreaterThanOrEqual(0); + const next = source.indexOf('\n - name: ', start + marker.length); + return source.slice(start, next === -1 ? source.length : next); +} + +/** Returns one named OpenCode workflow step. */ +function step(name) { + return namedStep(workflow, name); +} + +/** Returns one top-level CI job including its body but not the next job. */ +function ciJob(name) { + const marker = ` ${name}:\n`; + const start = ciWorkflow.indexOf(marker); expect(start).toBeGreaterThanOrEqual(0); - const next = workflow.indexOf('\n - name: ', start + marker.length); - return workflow.slice(start, next === -1 ? workflow.length : next); + const remainder = ciWorkflow.slice(start + marker.length); + const next = remainder.search(/^ [a-z][a-z0-9_]*:\n/mu); + return ciWorkflow.slice( + start, + next === -1 ? ciWorkflow.length : start + marker.length + next, + ); } describe('OpenCode commercial development workflow contract', () => { @@ -139,6 +175,103 @@ describe('OpenCode commercial development workflow contract', () => { expect(model).not.toContain('GH_TOKEN'); }); + it('uses one offline explicit NVIDIA model catalog instead of provider model discovery', () => { + const workspace = step('Prepare disposable model workspace'); + expect(workspace).toContain("'enabled_providers': ['nvidia']"); + expect(workspace).toContain("'model': model_label"); + expect(workspace).toContain("'small_model': model_label"); + expect(workspace).toContain("'whitelist': [model_id]"); + expect(workspace).toContain("'models': {model_id: {'name': model_id}}"); + expect(workspace).toContain("model_workspace_path / 'AGENTS.md'"); + expect(workspace).toContain("model_workspace_path / 'CLAUDE.md'"); + expect(workspace).toContain("'instructions': instruction_paths"); + expect(workspace).toContain("model_label.partition('/')"); + expect(workspace).toContain("provider_id != 'nvidia'"); + + const catalog = step('Validate the explicit OpenCode model catalog'); + expect(catalog).toContain('sudo -u opencode_model env -i'); + expect(catalog).toContain('OPENCODE_DISABLE_MODELS_FETCH=true'); + expect(catalog).toContain('OPENCODE_DISABLE_PROJECT_CONFIG=true'); + expect(catalog).toContain('opencode models nvidia'); + expect(catalog).toContain('test "$catalog" = "$2"'); + expect(catalog).not.toContain('NVIDIA_NIM_API_KEY'); + expect(catalog).not.toContain('/v1/models'); + + const model = step('Run one bounded OpenCode implementation'); + expect(model).toContain('OPENCODE_DISABLE_MODELS_FETCH=true'); + expect(model).toContain('OPENCODE_DISABLE_PROJECT_CONFIG=true'); + }); + + linuxX64Test( + 'registers a NVIDIA model absent from the bundled OpenCode catalog without discovery', + () => { + const temporaryRoot = mkdtempSync( + join(tmpdir(), 'life-os-opencode-catalog-'), + ); + try { + const modelId = 'cwl/contract-probe-model-v1'; + const modelLabel = `nvidia/${modelId}`; + const loopbackProbeValue = modelLabel; + expect(loopbackProbeValue).not.toHaveLength(0); + const opencodePackage = realpathSync( + resolve(import.meta.dirname, '../node_modules/opencode-ai'), + ); + const executable = resolve( + dirname(opencodePackage), + 'opencode-linux-x64/bin/opencode', + ); + const directories = Object.fromEntries( + ['home', 'cache', 'config', 'data', 'state'].map((name) => { + const path = resolve(temporaryRoot, name); + mkdirSync(path, { mode: 0o700 }); + return [name, path]; + }), + ); + const config = JSON.stringify({ + enabled_providers: ['nvidia'], + model: modelLabel, + small_model: modelLabel, + provider: { + nvidia: { + whitelist: [modelId], + models: { [modelId]: { name: modelId } }, + options: { + baseURL: 'http://127.0.0.1:8765/v1', + apiKey: loopbackProbeValue, + }, + }, + }, + }); + const configPath = resolve(directories.home, 'opencode.json'); + writeFileSync(configPath, config, { mode: 0o600 }); + + const result = spawnSync(executable, ['models', 'nvidia'], { + cwd: resolve(import.meta.dirname, '../../..'), + encoding: 'utf8', + timeout: 30_000, + env: { + HOME: directories.home, + PATH: process.env.PATH ?? '/usr/bin:/bin', + XDG_CACHE_HOME: directories.cache, + XDG_CONFIG_HOME: directories.config, + XDG_DATA_HOME: directories.data, + XDG_STATE_HOME: directories.state, + OPENCODE_CONFIG: configPath, + OPENCODE_DISABLE_AUTOUPDATE: 'true', + OPENCODE_DISABLE_MODELS_FETCH: 'true', + OPENCODE_DISABLE_PROJECT_CONFIG: 'true', + NVIDIA_API_KEY: loopbackProbeValue, + }, + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(modelLabel); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + }, + ); + it('isolates model writes from git, trusted policy, and trusted verifier authority', () => { const workspace = step('Prepare disposable model workspace'); expect(workspace).toContain('MODEL_WORKSPACE'); @@ -229,6 +362,54 @@ describe('OpenCode commercial development workflow contract', () => { expect(verification).not.toContain('NVIDIA_API_KEY'); expect(verification).not.toContain('GH_TOKEN'); expect(verification).not.toContain('GITHUB_TOKEN'); + expect(verification).not.toContain('docker compose'); + + const compose = step( + 'Validate Compose configuration through trusted boundary', + ); + expect(compose).toContain('docker compose'); + expect(compose).toContain('--file "$MODEL_WORKSPACE/compose.yaml"'); + expect(compose).toContain('--project-directory "$MODEL_WORKSPACE"'); + expect(compose).toContain('config --quiet'); + expect(compose).not.toContain('sudo -u opencode_model'); + expect(compose).not.toContain('${{ secrets.'); + }); + + it('boots and probes Compose services in credential-free pull-request CI', () => { + const runtimeJob = ciJob('compose_runtime'); + const validateJob = ciJob('validate'); + expect(validateJob).toContain('needs: compose_runtime'); + expect(runtimeJob).not.toContain('secrets.'); + expect(runtimeJob).not.toContain('GH_TOKEN'); + expect(runtimeJob).not.toContain('GITHUB_TOKEN'); + expect(runtimeJob).not.toMatch(/permissions:\s*\n\s+[^\n]+:\s*write/u); + + const runtime = namedStep( + runtimeJob, + 'Start and probe Compose infrastructure', + ); + expect(runtime).toContain( + 'docker compose up --detach --wait --wait-timeout 90', + ); + expect(runtime).toContain( + "docker compose exec --no-TTY postgres psql -U lifeos -d lifeos -v ON_ERROR_STOP=1 -tAc 'SELECT 1'", + ); + expect(runtime).toContain('http://127.0.0.1:8222/jsz'); + expect(runtime).toContain('jq -e \'(.streams | type) == "number"'); + expect(runtime).toContain( + 'docker compose logs --no-color --timestamps --tail 200 postgres nats', + ); + expect(runtime).toContain('docker compose down --volumes --remove-orphans'); + expect(compose).toMatch( + /image: postgres:17\.10-alpine@sha256:[a-f0-9]{64}/u, + ); + expect(compose).toMatch(/image: nats:2\.11\.6-alpine@sha256:[a-f0-9]{64}/u); + expect(compose).toContain("'127.0.0.1:5432:5432'"); + expect(compose).toContain("'127.0.0.1:4222:4222'"); + expect(compose).toContain("'127.0.0.1:8222:8222'"); + expect(compose).not.toContain("- '5432:5432'"); + expect(compose).not.toContain("- '4222:4222'"); + expect(compose).not.toContain("- '8222:8222'"); }); it('materializes and stages only the accepted evidence projection', () => { @@ -321,10 +502,22 @@ describe('OpenCode commercial development workflow contract', () => { const receipt = step('Compose credential-free development receipt'); expect(receipt).toContain( - `{'name': 'credential_bridge', 'status': 'skipped' if not selected else 'passed' if bridge_reason == 'completed' else 'failed'}`, + 'MODEL_CATALOG_OUTCOME: ${{ steps.model_catalog.outcome }}', + ); + expect(receipt).toContain( + `{'name': 'model_catalog', 'status': 'skipped' if not selected or open_prs != '0' else 'passed' if model_catalog_outcome == 'success' else 'failed'}`, + ); + expect(receipt).toContain( + `{'name': 'credential_bridge', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' else 'passed' if bridge_reason == 'completed' else 'failed'}`, + ); + expect(receipt).toContain( + `{'name': 'provider_run', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' or bridge_reason != 'completed' else 'passed' if model_reason == 'completed' else 'failed'}`, + ); + expect(receipt).toContain( + `{'name': 'diff_policy', 'status': 'skipped' if not selected or open_prs != '0' or model_catalog_outcome != 'success' or bridge_reason != 'completed' or model_reason != 'completed' else 'passed' if diff_accepted else 'failed'}`, ); expect(receipt).toContain( - `{'name': 'provider_run', 'status': 'skipped' if not selected or bridge_reason != 'completed' else 'passed' if model_reason == 'completed' else 'failed'}`, + "status, reason = 'failed', 'invalid_configuration'", ); }); diff --git a/packages/commercial-development-agent/vitest.config.mjs b/packages/commercial-development-agent/vitest.config.mjs index d0855f448..0525007d2 100644 --- a/packages/commercial-development-agent/vitest.config.mjs +++ b/packages/commercial-development-agent/vitest.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + testTimeout: 45_000, coverage: { provider: 'v8', include: ['src/**/*.mjs'],