diff --git a/.github/workflows/central-review.yml b/.github/workflows/central-review.yml index 2599a75a6..1da77ed50 100644 --- a/.github/workflows/central-review.yml +++ b/.github/workflows/central-review.yml @@ -442,59 +442,7 @@ jobs: NOEMA_LLM_MAX_RETRIES: ${{ vars.NOEMA_LLM_MAX_RETRIES || '1' }} run: | set -euo pipefail - python - <<'PY' - import json - import os - from urllib.parse import urlsplit, urlunsplit - from urllib.request import Request, urlopen - - api_url = os.environ.get("NOEMA_LLM_API_URL", "").strip() - api_key = os.environ.get("NOEMA_LLM_API_KEY", "").strip() - try: - parsed = urlsplit(api_url) - if parsed.scheme != "https" or not parsed.hostname: - raise ValueError("NOEMA_LLM_API_URL must be an absolute HTTPS URL") - if parsed.username or parsed.password or parsed.query or parsed.fragment: - raise ValueError( - "NOEMA_LLM_API_URL must not contain credentials, query, or fragment" - ) - if parsed.hostname.lower() in { - "api.openai.com", - "models.github.ai", - "openrouter.ai", - }: - raise ValueError( - "Noema production review must use contextual-orchestrator, " - "not a direct model provider" - ) - path = parsed.path.rstrip("/") - if not path.endswith("/v1"): - raise ValueError("NOEMA_LLM_API_URL must end in /v1") - if not api_key: - raise ValueError("NOEMA_LLM_API_KEY is not configured") - health_path = f"{path[:-3]}/healthz" or "/healthz" - health_url = urlunsplit( - (parsed.scheme, parsed.netloc, health_path, "", "") - ) - request = Request( - health_url, - headers={"Accept": "application/json", "User-Agent": "noema-reviewer"}, - ) - with urlopen(request, timeout=15) as response: # noqa: S310 - trusted org variable - raw = response.read(65_537) - if len(raw) > 65_536: - raise ValueError("contextual-orchestrator health response is too large") - health = json.loads(raw) - if health.get("status") != "ok" or health.get("service") != "contextual-orchestrator": - raise ValueError( - "NOEMA_LLM_API_URL did not identify contextual-orchestrator" - ) - except Exception as exc: - print(f"::error::Noema contextual-orchestrator preflight failed: {exc}") - raise SystemExit(1) from exc - - print("Verified contextual-orchestrator gateway identity.") - PY + node scripts/verify-orchestrator-gateway.mjs printf 'Noema provider contract: gateway=contextual-orchestrator primary=%s timeout=%ss retries=%s.\n' \ "${NOEMA_LLM_MODEL:-missing}" "${NOEMA_LLM_REQUEST_TIMEOUT_SECONDS:-missing}" \ "${NOEMA_LLM_MAX_RETRIES:-missing}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 755fde583..9c3d496d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,11 +56,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + NOEMA_EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail - if [[ ! "$NOEMA_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then - printf '::error::Invalid pull-request base SHA.\n' + if [[ ! "$NOEMA_EXPECTED_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]]; then + printf '::error::Invalid expected head SHA.\n' exit 1 fi if [ -z "$NOEMA_PR_BASE_REF" ]; then @@ -80,28 +80,25 @@ jobs: printf '::error::Live pull-request base ref did not resolve to a full commit SHA.\n' exit 1 fi - if [ "$live_base_sha" != "$NOEMA_PR_BASE_SHA" ]; then - printf '::error::Pull-request base branch advanced from %s to %s.\n' \ - "$NOEMA_PR_BASE_SHA" "$live_base_sha" + if ! git merge-base --is-ancestor "$live_base_sha" "$NOEMA_EXPECTED_HEAD_SHA"; then + printf '::error::Pull-request head does not contain the current live base %s.\n' "$live_base_sha" exit 1 fi - test "$live_base_sha" = "$NOEMA_PR_BASE_SHA" + printf 'NOEMA_LIVE_BASE_SHA=%s\n' "$live_base_sha" >> "$GITHUB_ENV" - name: verify lockfile change control if: github.event_name == 'pull_request' shell: bash - env: - NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | set -euo pipefail - if [[ ! "$NOEMA_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then - printf '::error::Invalid pull-request base SHA.\n' + if [[ ! "$NOEMA_LIVE_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + printf '::error::Invalid live pull-request base SHA.\n' exit 1 fi base_lock="$RUNNER_TEMP/noema-package-lock-base.json" - git show "${NOEMA_PR_BASE_SHA}:package-lock.json" >"$base_lock" + git show "${NOEMA_LIVE_BASE_SHA}:package-lock.json" >"$base_lock" NOEMA_LOCKFILE_BASE_PATH="$base_lock" \ - NOEMA_LOCKFILE_BASE_SHA="$NOEMA_PR_BASE_SHA" \ + NOEMA_LOCKFILE_BASE_SHA="$NOEMA_LIVE_BASE_SHA" \ node --input-type=module <<'NODE' import { runLockfileChangeControl } from "./scripts/lockfile-change-control.mjs"; @@ -127,11 +124,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }} - NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | set -euo pipefail - if [[ ! "$NOEMA_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then - printf '::error::Invalid pull-request base SHA.\n' + if [[ ! "$NOEMA_LIVE_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + printf '::error::Initial live pull-request base SHA is unavailable.\n' exit 1 fi if [ -z "$NOEMA_PR_BASE_REF" ]; then @@ -151,9 +147,9 @@ jobs: printf '::error::Live pull-request base ref did not resolve to a full commit SHA.\n' exit 1 fi - if [ "$live_base_sha" != "$NOEMA_PR_BASE_SHA" ]; then + if [ "$live_base_sha" != "$NOEMA_LIVE_BASE_SHA" ]; then printf '::error::Pull-request base branch advanced during verification from %s to %s.\n' \ - "$NOEMA_PR_BASE_SHA" "$live_base_sha" + "$NOEMA_LIVE_BASE_SHA" "$live_base_sha" exit 1 fi - test "$live_base_sha" = "$NOEMA_PR_BASE_SHA" + test "$live_base_sha" = "$NOEMA_LIVE_BASE_SHA" diff --git a/.github/workflows/hourly-product-development.yml b/.github/workflows/hourly-product-development.yml index 06b96d37d..d78793b2d 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -1,4 +1,4 @@ -name: Hourly NVIDIA NIM Product Development +name: Hourly Orchestrator Product Development on: workflow_dispatch: @@ -12,7 +12,7 @@ on: - cron: "47 * * * *" concurrency: - group: hourly-nim-product-development-${{ github.repository }} + group: hourly-orchestrator-product-development-${{ github.repository }} cancel-in-progress: false permissions: @@ -22,15 +22,9 @@ env: DEFAULT_BRANCH: main OPENCODE_VERSION: "1.17.13" OPENCODE_SHA256: 157afa289d1a8d9372de0ce19ac726119b937a1f6b201808d46f06e4e59bb348 - OPENCODE_MODEL_CANDIDATES: >- - nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5 - nvidia-nim/nvidia/nemotron-3-super-120b-a12b - nvidia-nim/deepseek-ai/deepseek-v4-pro - # Three candidate budgets and two inter-candidate cleanup reinstalls fit in 55 minutes. - OPENCODE_RUN_TIMEOUT_SECONDS: "900" + # One gateway-backed session plus setup/diagnostic reserve fits in 55 minutes. + OPENCODE_RUN_TIMEOUT_SECONDS: "2700" OPENCODE_KILL_GRACE_SECONDS: "30" - DEPENDENCY_REINSTALL_TIMEOUT_SECONDS: "60" - DEPENDENCY_REINSTALL_KILL_GRACE_SECONDS: "10" MAX_CHANGED_FILES: "40" MAX_DIFF_BYTES: "500000" MAX_PR_TITLE_BYTES: "120" @@ -62,7 +56,8 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} - NIM_CONFIGURED: ${{ secrets.NVIDIA_NIM_API_KEY != '' }} + ORCHESTRATOR_KEY_CONFIGURED: ${{ secrets.NOEMA_LLM_API_KEY != '' }} + ORCHESTRATOR_URL_CONFIGURED: ${{ vars.NOEMA_LLM_API_URL != '' }} MAINTAINER_APP_CLIENT_ID_CONFIGURED: ${{ vars.NOEMA_MAINTAINER_APP_CLIENT_ID != '' }} MAINTAINER_APP_PRIVATE_KEY_CONFIGURED: ${{ secrets.NOEMA_MAINTAINER_APP_PRIVATE_KEY != '' }} run: | @@ -94,12 +89,14 @@ jobs: exit 0 fi - if [ "$NIM_CONFIGURED" != "true" ] && [ "$DRY_RUN" != "true" ]; then + if { [ "$ORCHESTRATOR_KEY_CONFIGURED" != "true" ] \ + || [ "$ORCHESTRATOR_URL_CONFIGURED" != "true" ]; } \ + && [ "$DRY_RUN" != "true" ]; then { echo "dispatch=false" - echo "reason=nim_api_key_unavailable" + echo "reason=orchestrator_gateway_unavailable" } >>"$GITHUB_OUTPUT" - echo "Autonomous development is disabled because the dedicated NVIDIA NIM secret is unavailable." \ + echo "Autonomous development is disabled because the contextual-orchestrator gateway is unavailable." \ >>"$GITHUB_STEP_SUMMARY" exit 0 fi @@ -118,7 +115,8 @@ jobs: { echo "dispatch=true" - if [ "$NIM_CONFIGURED" = "true" ] \ + if [ "$ORCHESTRATOR_KEY_CONFIGURED" = "true" ] \ + && [ "$ORCHESTRATOR_URL_CONFIGURED" = "true" ] \ && [ "$MAINTAINER_APP_CLIENT_ID_CONFIGURED" = "true" ] \ && [ "$MAINTAINER_APP_PRIVATE_KEY_CONFIGURED" = "true" ]; then echo "reason=ready" @@ -145,9 +143,12 @@ jobs: Keep Noema independently deployable and preserve its modular MSA role with ContextualWisdomLab/.github, naruon, contextual-orchestrator, and other CWL - services. Keep interfaces explicit and replaceable. Use or improve - contextual-orchestrator for every new product-runtime LLM path. Do not alter - the existing reviewer-agent credential names, trust boundary, or provider route. + services. Keep interfaces explicit and replaceable. Route every Noema LLM + job through contextual-orchestrator. Do not sequentially try the next model + or agent inside Noema; the orchestrator selects min-cost / max-performance. + Do not call NVIDIA NIM, Bytez, OpenRouter, OpenAI, or GitHub Models directly. + Do not alter the existing reviewer App identity, OIDC token-broker, or + sandbox boundaries. The credential-bearing proposer has no shell execution authority. Do not claim that you executed tests or shell commands. Work test-first at the proposal boundary: add @@ -235,6 +236,18 @@ jobs: shell: bash run: npm ci --ignore-scripts + - name: Verify contextual-orchestrator gateway and write OpenCode config + if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' + shell: bash + env: + NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL }} + NOEMA_LLM_MODEL: ${{ vars.NOEMA_LLM_MODEL }} + run: | + set -euo pipefail + node scripts/verify-orchestrator-gateway.mjs \ + --write-opencode-config "$RUNNER_TEMP/opencode.json" + chmod 0400 "$RUNNER_TEMP/opencode.json" + - name: Install checksum-pinned OpenCode CLI if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' shell: bash @@ -255,71 +268,11 @@ jobs: "${install_dir}/opencode" --version echo "$install_dir" >>"$GITHUB_PATH" - - name: Configure OpenCode for NVIDIA NIM only - if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' - shell: bash - run: | - set -euo pipefail - cat >"$RUNNER_TEMP/opencode.json" <<'CONFIG' - { - "$schema": "https://opencode.ai/config.json", - "share": "disabled", - "autoupdate": false, - "lsp": false, - "mcp": {}, - "enabled_providers": ["nvidia-nim"], - "model": "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5", - "small_model": "nvidia-nim/meta/llama-3.3-70b-instruct", - "permission": { - "*": "allow", - "external_directory": "deny", - "task": "deny", - "question": "deny", - "webfetch": "deny", - "websearch": "deny", - "bash": "deny" - }, - "provider": { - "nvidia-nim": { - "npm": "@ai-sdk/openai-compatible", - "name": "NVIDIA NIM", - "options": { - "baseURL": "https://integrate.api.nvidia.com/v1", - "apiKey": "{env:NVIDIA_API_KEY}" - }, - "models": { - "nvidia/llama-3.3-nemotron-super-49b-v1.5": { - "name": "NVIDIA Llama 3.3 Nemotron Super 49B v1.5", - "tool_call": true, - "limit": {"context": 131072, "output": 8192} - }, - "nvidia/nemotron-3-super-120b-a12b": { - "name": "NVIDIA Nemotron 3 Super 120B", - "tool_call": true, - "limit": {"context": 131072, "output": 8192} - }, - "deepseek-ai/deepseek-v4-pro": { - "name": "DeepSeek V4 Pro through NVIDIA NIM", - "tool_call": true, - "limit": {"context": 131072, "output": 8192} - }, - "meta/llama-3.3-70b-instruct": { - "name": "Meta Llama 3.3 70B Instruct through NVIDIA NIM", - "tool_call": true, - "limit": {"context": 131072, "output": 8192} - } - } - } - } - } - CONFIG - chmod 0400 "$RUNNER_TEMP/opencode.json" - - - name: Run bounded NVIDIA NIM model fallback + - name: Run one contextual-orchestrator OpenCode session if: steps.gate.outputs.dispatch == 'true' && env.DRY_RUN != 'true' shell: bash env: - NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }} + NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY }} OPENCODE_DISABLE_AUTOUPDATE: "true" OPENCODE_CONFIG: ${{ runner.temp }}/opencode.json XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-config @@ -327,57 +280,25 @@ jobs: run: | set -euo pipefail prompt="$(cat "$RUNNER_TEMP/noema-agent-prompt.md")" - status=1 - cleanup_failed=false - read -r -a model_candidates <<<"$OPENCODE_MODEL_CANDIDATES" - candidate_count=${#model_candidates[@]} - - for ((candidate_index = 0; candidate_index < candidate_count; candidate_index++)); do - model="${model_candidates[$candidate_index]}" - echo "::group::OpenCode candidate $model" - if timeout --kill-after="${OPENCODE_KILL_GRACE_SECONDS}s" "${OPENCODE_RUN_TIMEOUT_SECONDS}s" \ - env -u GH_TOKEN -u GITHUB_TOKEN \ - -u REPOSITORY_TOKEN \ - -u ACTIONS_ID_TOKEN_REQUEST_TOKEN \ - -u ACTIONS_ID_TOKEN_REQUEST_URL \ - -u ACTIONS_RUNTIME_TOKEN \ - -u ACTIONS_RUNTIME_URL \ - -u ACTIONS_RESULTS_URL \ - -u ACTIONS_CACHE_URL \ - -u GITHUB_ENV \ - -u GITHUB_OUTPUT \ - -u GITHUB_PATH \ - -u GITHUB_STATE \ - -u GITHUB_STEP_SUMMARY \ - opencode run "$prompt" --agent build --model "$model"; then - status=0 - echo "::endgroup::" - echo "OpenCode completed one proposal with \`$model\`." \ - >>"$GITHUB_STEP_SUMMARY" - break - fi - - echo "::endgroup::" - if [ "$candidate_index" -eq $((candidate_count - 1)) ]; then - break - fi - - echo "::warning::Candidate $model failed; discarding its partial working tree before the next candidate." - git -C "$GITHUB_WORKSPACE" reset --hard HEAD - git -C "$GITHUB_WORKSPACE" clean -fdx - if ! timeout --kill-after="${DEPENDENCY_REINSTALL_KILL_GRACE_SECONDS}s" "${DEPENDENCY_REINSTALL_TIMEOUT_SECONDS}s" npm ci --ignore-scripts; then - echo "::error::Candidate $model cleanup dependency reinstall failed or timed out; no later candidate will run." - cleanup_failed=true - break - fi - done - - if [ "$status" -ne 0 ]; then - if [ "$cleanup_failed" = "true" ]; then - echo "::error::Every NVIDIA NIM candidate failed or bounded cleanup failed closed; no branch or pull request was created." - else - echo "::error::Every NVIDIA NIM candidate failed; no branch or pull request was created." - fi + if timeout --kill-after="${OPENCODE_KILL_GRACE_SECONDS}s" "${OPENCODE_RUN_TIMEOUT_SECONDS}s" \ + env -u GH_TOKEN -u GITHUB_TOKEN \ + -u REPOSITORY_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_TOKEN \ + -u ACTIONS_ID_TOKEN_REQUEST_URL \ + -u ACTIONS_RUNTIME_TOKEN \ + -u ACTIONS_RUNTIME_URL \ + -u ACTIONS_RESULTS_URL \ + -u ACTIONS_CACHE_URL \ + -u GITHUB_ENV \ + -u GITHUB_OUTPUT \ + -u GITHUB_PATH \ + -u GITHUB_STATE \ + -u GITHUB_STEP_SUMMARY \ + opencode run "$prompt" --agent build; then + echo "OpenCode completed one proposal through contextual-orchestrator." \ + >>"$GITHUB_STEP_SUMMARY" + else + echo "::error::The contextual-orchestrator OpenCode session failed; no branch or pull request was created." exit 1 fi @@ -391,7 +312,7 @@ jobs: if [ -z "$(git status --porcelain)" ]; then echo "has_changes=false" >>"$GITHUB_OUTPUT" - echo "OpenCode produced no working-tree change; this hour is a no-op." \ + echo "The orchestrator-backed OpenCode session produced no working-tree change; this hour is a no-op." \ >>"$GITHUB_STEP_SUMMARY" exit 0 fi @@ -771,8 +692,8 @@ jobs: else printf '%s' 'Noema autonomous commercial increment' >"$title_file" cat >"$body_file" <<'BODY' - OpenCode produced one bounded NVIDIA NIM increment. Review the diff, - CHANGELOG.md, doctoring, and exact-head verification before merge. + OpenCode produced one bounded contextual-orchestrator increment. Review + the diff, CHANGELOG.md, doctoring, and exact-head verification before merge. BODY fi @@ -841,7 +762,7 @@ jobs: umask 077 title="$(cat "$RUNNER_TEMP/pr-title.txt")" body_file="$RUNNER_TEMP/pr-body.md" - branch="nim-agent/product-dev-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + branch="orchestrator-agent/product-dev-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" expected_base="${{ needs.propose_product_increment.outputs.base_sha }}" repo_owner="${GITHUB_REPOSITORY%%/*}" @@ -982,4 +903,4 @@ jobs: { echo "Opened bounded pull request: $pr_url" echo "hourly-commercial-readiness owns review, repair, exact-head revalidation, and merge." - } >>"$GITHUB_STEP_SUMMARY" \ No newline at end of file + } >>"$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index 7b1b6ad6f..c77ea51d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,34 @@ Worker (npm + `wrangler.toml`); tests run under Vitest. (file paths, thresholds) only; that is build-time config, out of scope for this rule. If any script ever needs a real secret, source it from the KV, not the environment. + +### LLM gateway (all Noema LLM jobs, reusable by naruon) +- Noema is a multi-purpose bot, not only a review bot. It also runs as a + separate agent program inside `ContextualWisdomLab/naruon` for judgments and + decisions. naruon is a **first-class consumer** of this contract; naruon + wiring is a separate repository PR. +- Every LLM job — production review, hourly product development, naruon + judgments/decisions, and any later job — calls + `ContextualWisdomLab/contextual-orchestrator` through the same contract: + `NOEMA_LLM_API_URL` is an HTTPS OpenAI-compatible base ending in `/v1`, + `NOEMA_LLM_MODEL` is normally the routing alias `contextual-orchestrator`, and + `NOEMA_LLM_API_KEY` is a dedicated gateway inference token. +- The reusable, secret-free copy is `contracts/orchestrator-gateway.json` + (`node scripts/verify-orchestrator-gateway.mjs --print-contract`). Narrative: + `docs/orchestrator-gateway-consumer-contract.md`. Validation helpers live in + `scripts/lib/orchestrator-gateway.mjs`. Do not copy the OpenCode config writer + into naruon. +- Upstream provider keys (`NVIDIA_NIM_API_KEY`, `NVIDIA_NIM_API_KEY_SUB`, + `BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`) belong in the + orchestrator credential KV, not in Noema or naruon runtime, workflows, or + this repository. Never `COPILOT_GITHUB_TOKEN`. +- Do **not** sequentially try the next model or agent inside Noema or naruon. + The orchestrator itself picks min-cost / max-performance. Do not configure a + direct-provider fallback. Shared preflight lives in + `scripts/verify-orchestrator-gateway.mjs`. +- Keep the OIDC token-broker, GitHub App identities, and sandbox/runner + isolation boundaries intact. Do not clone an OpenCode sidecar or copy + OpenCode bot model-candidate lists. ## Code-owner review gates — disabled (on hold) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa8594be..b231732af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- 비리뷰 LLM 작업인 `hourly-product-development`를 리뷰와 동일한 `contextual-orchestrator` 게이트웨이 계약(`NOEMA_LLM_API_URL` `/v1`, 모델 별칭 `contextual-orchestrator`, 전용 `NOEMA_LLM_API_KEY`)으로 전환한다. Llama Nemotron → Nemotron Super → DeepSeek 순차 NIM 후보 폴백과 `NVIDIA_NIM_API_KEY` 직접 호출을 제거하고, 공유 `scripts/verify-orchestrator-gateway.mjs`가 `/healthz` 신원과 직접 공급자 호스트를 실패-폐쇄한다. 리뷰어의 `NOEMA_FALLBACK_*` / PydanticAI `FallbackModel` 순차 폴백도 제거해 남은 설정은 실패-폐쇄한다. 동일 계약을 `contracts/orchestrator-gateway.json`으로 공개해 `ContextualWisdomLab/naruon` 판단·결정 에이전트가 1급 소비자로 재사용할 수 있게 한다. naruon 배선은 별도 저장소 PR이다. 상위 공급자 키는 오케스트레이터 KV에 남기며 OIDC 토큰 중개·App 신원·3-runner 샌드박스 경계는 유지한다. - 검증된 active-orphan 워크플로 하나를 운영자가 호출할 수 있는 `operations:workflow-registry-disable` 경로를 추가한다. 저장소와 워크플로 ID를 `NOEMA_MAINTAINER_TOKEN_PATH` 위임 토큰 파일 읽기 전에 검사하고, 신선한 전체 레지스트리 감사·즉시 live refresh·프로세스 로컬 plan·보호된 main/워크플로 재검증·사후 전체 감사 봉투(`schema_version` 1, `PASS`/`FAIL`, `remaining_failure_codes`, `remaining_active_orphan_ids`)를 통과한 뒤에만 영수증을 유지한다. 성공 종료와 `post_audit_status: FAIL`은 해당 ID만 `disabled_manually`가 되었고 레지스트리는 아직 더럽을 수 있음을 뜻하므로, 운영자는 영수증의 `remaining_active_orphan_ids`로 다음 단일 호출을 이어간다. 배치 비활성화·자가 수리 워크플로·거버넌스 완화는 추가하지 않으며 호출 계약은 doctoring에 기록한다. - 읽기 전용 `operations:runner-assignment` audit를 추가해 exact workflow run/source head에 대한 runner assignment를 완전 pagination으로 진단하고, 신선한 unassigned queue는 bounded grace 이후 실패-폐쇄한다. 이 증빙은 runner assignment와 required Check/CI, formal review, merge, release, deployment authority를 분리하며 assigned runner 이후 workflow failure를 성공으로 승격하지 않는다. - production `operations:runner-assignment` audit는 `NOEMA_MAINTAINER_TOKEN_PATH`의 owner-only capability file만 읽고, ambient `GH_TOKEN`만 있으면 실패-폐쇄한다. `gh` spawn/stderr 진단은 활성 토큰을 exact-match로 `[REDACTED]` 치환하며, 빈 secret에 대해서는 원문 진단을 보존한다. assignment authority는 양의 `runner_id` 또는 비어 있지 않은 `runner_name`만 인정하며 queued `started_at`은 assignment evidence가 아니다. 운영자는 `printf '%s'`로 capability file을 만들고(`echo`/`printf '%s\\n'`는 trailing newline 때문에 실패-폐쇄), Actions workflow-run/job read만 가진 짧은 토큰을 준비한 뒤 PASS를 required Check·formal review·merge 권한으로 해석하지 마십시오. diff --git a/CLAUDE.md b/CLAUDE.md index fb97f4579..f3aa373b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What noema is -Noema is ContextualWisdomLab's GitHub App token exchange service for an independent LLM pull request reviewer. It is a single TypeScript Cloudflare Worker (Free tier): GitHub Actions presents a GitHub OIDC token (audience `cwl-noema-review`), noema verifies issuer/audience/org owner/trusted central workflow identity, then exchanges it for a GitHub App installation token scoped to the target repository with minimal permissions (`pull_requests: write`, `contents: read`, `checks: read`). The central `ContextualWisdomLab/.github` workflow uses that token to post LLM review verdicts under a separate App identity. +Noema is ContextualWisdomLab's multi-purpose GitHub App bot. The Cloudflare Worker (Free tier) remains the OIDC token broker: GitHub Actions presents a GitHub OIDC token (audience `cwl-noema-review`), noema verifies issuer/audience/org owner/trusted central workflow identity, then exchanges it for a GitHub App installation token scoped to the target repository with minimal permissions (`pull_requests: write`, `contents: read`, `checks: read`). Review is one job, not the only job. Noema also runs as a separate agent program inside `ContextualWisdomLab/naruon` for judgments and decisions; naruon is a first-class consumer of the same gateway contract (wiring is a separate naruon PR). Every LLM path — production review, hourly product development, and naruon judgments — calls `contextual-orchestrator` (`NOEMA_LLM_API_URL` ending in `/v1`, model normally `contextual-orchestrator`, dedicated `NOEMA_LLM_API_KEY`). The reusable contract is `contracts/orchestrator-gateway.json`. Noema does not sequentially try the next model or hold upstream provider keys. ## Commands diff --git a/README.md b/README.md index 8994afa08..3d1169604 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # Noema -Noema is ContextualWisdomLab's dedicated GitHub App token exchange service for an independent LLM pull request reviewer. +Noema is ContextualWisdomLab's multi-purpose GitHub App bot: an OIDC token +broker, an independent LLM pull request reviewer, hourly product development, +and a first-class agent contract for `ContextualWisdomLab/naruon` judgments +and decisions. naruon wiring is a separate repository pull request; this +repository publishes the reusable gateway contract. It runs as a Cloudflare Worker on the Free tier: @@ -19,6 +23,11 @@ The LLM call itself is configured in the central workflow with: - `NOEMA_LLM_API_KEY` — a dedicated gateway inference token, never an upstream provider key +The same contract is reusable by naruon. The secret-free machine-readable +copy is [`contracts/orchestrator-gateway.json`](./contracts/orchestrator-gateway.json); +see [Orchestrator gateway consumer contract](./docs/orchestrator-gateway-consumer-contract.md). +Print it with `node scripts/verify-orchestrator-gateway.mjs --print-contract`. + The product repository also owns the default-branch-only [`central-review`](./.github/workflows/central-review.yml) runtime. It accepts a `noema-review` `repository_dispatch` event containing `target_repository`, @@ -88,11 +97,12 @@ Set `NOEMA_EXCHANGE_URL` in `ContextualWisdomLab/.github` variables to the deplo - [API 명세](./docs/api-spec.md) - [안정성 계약](./docs/api-stability-contract.md) +- [Orchestrator gateway consumer contract](./docs/orchestrator-gateway-consumer-contract.md) - [온보딩 가이드](./docs/onboarding.md) - [운영 Runbook](./docs/runbook.md) - [Distributed Rate Limiting](./docs/distributed-rate-limiting.md) - [Hourly Commercial-Readiness Loop](./docs/hourly-commercial-readiness-loop.md) -- [Hourly NVIDIA NIM Product Development](./docs/operations/hourly-product-development.md) +- [Hourly Orchestrator Product Development](./docs/operations/hourly-product-development.md) - [SLA/지원 정책](./docs/sla-and-support.md) - [가격 초안](./docs/pricing-draft.md) - [관측성 KPI](./docs/observability-kpi.md) @@ -111,7 +121,7 @@ Set `NOEMA_EXCHANGE_URL` in `ContextualWisdomLab/.github` variables to the deplo - [Transfer Readiness Plan](./docs/transfer-readiness-plan.md) - [Library Boundary Decision](./docs/library-boundary-decision.md) -`hourly-product-development.yml` runs a proposal-only OpenCode session through the dedicated `NVIDIA_NIM_API_KEY` credential when the PR queue is empty. It cannot review, merge, release, or deploy; the existing hourly commercial-readiness loop retains exact-head governance and SHA-bound merge authority. +`hourly-product-development.yml` runs a proposal-only OpenCode session through the same `contextual-orchestrator` gateway contract as review (`NOEMA_LLM_API_URL`, `NOEMA_LLM_MODEL`, dedicated `NOEMA_LLM_API_KEY`) when the PR queue is empty. It does not iterate a model-candidate list. It cannot review, merge, release, or deploy; the existing hourly commercial-readiness loop retains exact-head governance and SHA-bound merge authority. ## KPI 계산 diff --git a/contracts/orchestrator-gateway.json b/contracts/orchestrator-gateway.json new file mode 100644 index 000000000..cc51e29be --- /dev/null +++ b/contracts/orchestrator-gateway.json @@ -0,0 +1,65 @@ +{ + "id": "contextual-orchestrator-gateway", + "version": 1, + "service": "contextual-orchestrator", + "routing_alias": "contextual-orchestrator", + "api_url": { + "scheme": "https", + "pathname_suffix": "/v1", + "allow_userinfo": false, + "allow_query": false, + "allow_fragment": false + }, + "healthz": { + "unauthenticated": true, + "identity": { + "status": "ok", + "service": "contextual-orchestrator" + } + }, + "transport_names": { + "api_url": "NOEMA_LLM_API_URL", + "model": "NOEMA_LLM_MODEL", + "api_key": "NOEMA_LLM_API_KEY" + }, + "dedicated_inference_token": true, + "sequential_model_candidates": false, + "forbidden_provider_keys": [ + "NVIDIA_NIM_API_KEY", + "NVIDIA_NIM_API_KEY_SUB", + "BYTEZ_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "COPILOT_GITHUB_TOKEN" + ], + "forbidden_direct_provider_hosts": [ + "api.openai.com", + "models.github.ai", + "openrouter.ai", + "integrate.api.nvidia.com", + "api.nvidia.com", + "api.bytez.com" + ], + "consumers": [ + { + "id": "noema-review", + "repository": "ContextualWisdomLab/noema", + "role": "github-review", + "wiring": "this-repository" + }, + { + "id": "noema-hourly-product-development", + "repository": "ContextualWisdomLab/noema", + "role": "product-development", + "wiring": "this-repository" + }, + { + "id": "naruon-judgments", + "repository": "ContextualWisdomLab/naruon", + "role": "judgments-and-decisions", + "wiring": "separate-repository-pr" + } + ], + "naruon_first_class_consumer": true, + "naruon_wiring": "separate-repository-pr" +} diff --git a/docs/buyer-due-diligence-index.md b/docs/buyer-due-diligence-index.md index a67729958..19752bfde 100644 --- a/docs/buyer-due-diligence-index.md +++ b/docs/buyer-due-diligence-index.md @@ -18,6 +18,7 @@ Manifest의 최종 evidence 항목은 파일 존재와 SHA-256 색인을 남긴 |---|---|---| | 제품 설명 | `README.md`, `docs/demo-scenario.md`, `docs/buyer-pitch-deck-outline.md` | ready | | API 명세 | `docs/api-spec.md`, `docs/api-stability-contract.md` | ready | +| LLM 게이트웨이 소비자 계약 | `contracts/orchestrator-gateway.json`, `docs/orchestrator-gateway-consumer-contract.md` | ready; naruon 배선은 별도 PR | | 온보딩 | `docs/onboarding.md`, `docs/pilot-readiness-checklist.md` | ready | | 가격/계약 | `docs/pricing-draft.md`, `docs/terms-draft.md`, `docs/sla-and-support.md` | draft | | Figma/FigJam 구매자 설명 자산 | `https://www.figma.com/board/8l2fELfENAABNhDTMEVJKt` (Figma Code Connect 미사용) | ready | diff --git a/docs/contextual-orchestrator-reviewer-cutover.md b/docs/contextual-orchestrator-reviewer-cutover.md index 4db1fd8a2..9e218e870 100644 --- a/docs/contextual-orchestrator-reviewer-cutover.md +++ b/docs/contextual-orchestrator-reviewer-cutover.md @@ -1,10 +1,16 @@ -# Contextual-orchestrator reviewer cutover +# Contextual-orchestrator LLM cutover -This runbook moves the trusted Noema production reviewer from a direct external -model endpoint to the organization `contextual-orchestrator` gateway. The code -change and the live organization configuration change are intentionally -separate: the latter creates or changes credentials and requires an explicit -operator approval. +This runbook moves every trusted Noema LLM job — production review, +hourly product development, and the published naruon consumer contract — +from a direct external model endpoint to the organization +`contextual-orchestrator` gateway. naruon is a first-class consumer for +judgments and decisions; naruon wiring is a separate repository pull +request. The code change and the live organization configuration change +are intentionally separate: the latter creates or changes credentials and +requires an explicit operator approval. + +The reusable contract is `contracts/orchestrator-gateway.json` and +`docs/orchestrator-gateway-consumer-contract.md`. ## Target contract @@ -19,8 +25,10 @@ operator approval. failover, allowlists, budgets, circuit breakers, and audit stay in the gateway. -The central workflow rejects known direct OpenAI, GitHub Models, and OpenRouter -hosts even if they implement an OpenAI-compatible API. +Every Noema LLM workflow rejects known direct OpenAI, GitHub Models, +OpenRouter, NVIDIA NIM, and Bytez hosts even if they implement an +OpenAI-compatible API. Noema does not sequentially try the next model or +agent; the orchestrator selects min-cost / max-performance. ## Approval-bound activation @@ -40,13 +48,18 @@ workflow logs, or this repository. 5. Dispatch a canary review against a draft pull request at an exact current head SHA. Confirm the Noema App review, gateway audit event, chosen upstream, and cost/budget record all refer to the same request. -6. Only after the canary succeeds, retire the old direct `OPENAI_API_KEY` - dependency from the Noema review path. Do not delete an organization secret - until all unrelated consumers are inventoried. +6. Dispatch a dry-run, then a live hourly product-development canary only when + the pull-request queue is empty. Confirm the OpenCode session used the same + gateway identity and did not iterate a model-candidate list. +7. Only after both canaries succeed, retire direct `OPENAI_API_KEY` and + `NVIDIA_NIM_API_KEY` dependencies from Noema LLM jobs. Do not delete an + organization secret until all unrelated consumers are inventoried. Those + provider keys belong in the orchestrator credential KV. ## Rollback -If the gateway health or canary review fails, leave the Noema review unavailable -and restore the last reviewed gateway deployment or configuration. Do not -silently route the production reviewer directly to an external provider; that -would bypass the control plane this cutover is meant to establish. +If the gateway health or a canary job fails, leave the affected Noema LLM job +unavailable and restore the last reviewed gateway deployment or configuration. +Do not silently route review or product development directly to an external +provider; that would bypass the control plane this cutover is meant to +establish. diff --git a/docs/doctoring/atomic-product-publisher-lease.md b/docs/doctoring/atomic-product-publisher-lease.md index fa2002aa4..5c36fd9b7 100644 --- a/docs/doctoring/atomic-product-publisher-lease.md +++ b/docs/doctoring/atomic-product-publisher-lease.md @@ -2,7 +2,7 @@ ## Status and scope -Reviewed on 2026-08-16 against protected `main` `28af0b1c2e76d066a5d41ef1da56157209c89431`. This record applies only to the credential-bearing `publish_product_increment` stage in `.github/workflows/hourly-product-development.yml`. It does not grant review, merge, release, deployment, or licensing authority, and it does not change the NVIDIA NIM proposer/verifier trust split already present on protected main. +Reviewed on 2026-08-16 against protected `main` `28af0b1c2e76d066a5d41ef1da56157209c89431`. This record applies only to the credential-bearing `publish_product_increment` stage in `.github/workflows/hourly-product-development.yml`. It does not grant review, merge, release, deployment, or licensing authority, and it does not change the proposer/verifier/publisher trust split already present on protected main. This successor rebuilds only the unique atomic publisher behavior from #378 on the current protected lineage after #373 advanced `main`. The #378 branch and its checks/reviews remain predecessor evidence; no CI, review, scanner, or coverage result transfers to this successor. @@ -54,7 +54,7 @@ After creation, the publisher re-reads that exact pull request and requires `hea ## Authority and rollback boundaries -The proposer may use `NVIDIA_NIM_API_KEY` but has no shell execution authority. A separate uncredentialed job executes `npm run release:verify`; the credential-bearing publisher reconstructs and publishes only the already verified immutable proposal. The Maintainer App token never becomes model/verifier authority. This change does not weaken the central Security Scan, configured coverage, package, SBOM/provenance, review, protected-base, or release gates. +The proposer may use the dedicated `NOEMA_LLM_API_KEY` gateway token but has no shell execution authority. A separate uncredentialed job executes `npm run release:verify`; the credential-bearing publisher reconstructs and publishes only the already verified immutable proposal. The Maintainer App token never becomes model/verifier authority. This change does not weaken the central Security Scan, configured coverage, package, SBOM/provenance, review, protected-base, or release gates. Rollback is source rollback of this bounded publisher change. No force-push, destructive rebase, branch-protection bypass, self-approval, repair workflow, or alternate credential is required. diff --git a/docs/doctoring/hourly-nim-opencode-development.md b/docs/doctoring/hourly-nim-opencode-development.md index 9198e765e..3eaa5b07f 100644 --- a/docs/doctoring/hourly-nim-opencode-development.md +++ b/docs/doctoring/hourly-nim-opencode-development.md @@ -1,4 +1,4 @@ -# Hourly NVIDIA NIM OpenCode development: evidence and trust boundaries +# Hourly contextual-orchestrator OpenCode development: evidence and trust boundaries ## Documentation standard @@ -8,13 +8,13 @@ This doctoring note uses APA 7 reference form. It separates externally supported ### OpenCode -OpenCode documents `opencode run` as non-interactive execution for automation. Its configuration supports custom OpenAI-compatible providers, environment-bound API keys, explicit model maps and limits, granular tool permissions, and disabled session sharing. These capabilities support a repository-local NVIDIA NIM provider without GitHub Copilot or the OpenCode GitHub integration. +OpenCode documents `opencode run` as non-interactive execution for automation. Its configuration supports custom OpenAI-compatible providers, environment-bound API keys, explicit model maps and limits, granular tool permissions, and disabled session sharing. These capabilities support a repository-local `contextual-orchestrator` provider without GitHub Copilot, a sequential model-candidate list, or the OpenCode GitHub integration. Noema pins OpenCode 1.17.13 and the reviewed Linux x64 archive digest rather than following a mutable latest release. The pin is an organization reproducibility decision already used by CWL repositories; it is not represented as the newest available release. Any upgrade requires a newly reviewed exact version, official artifact, digest, compatibility test, and changelog entry. -### NVIDIA NIM +### Contextual-orchestrator gateway -NVIDIA documents NIM large-language-model inference through OpenAI-compatible API surfaces. Chat completions, responses, streaming, and tool calling depend on the selected model and deployment profile. The workflow therefore treats each hosted model as a fallible candidate rather than a guaranteed capability. A candidate timeout or provider error triggers clean fallback; every candidate failure produces no PR. +The organization `contextual-orchestrator` service exposes an OpenAI-compatible `/v1` surface and an unauthenticated `/healthz` identity document. OpenAI documents that compatible clients send chat-completion requests to a base URL ending in `/v1`. Noema therefore treats the gateway as the only production model endpoint: one routing alias, one dedicated inference token, and no sequential per-model failover inside this repository. The same secret-free contract is published for `ContextualWisdomLab/naruon` judgments and decisions; naruon is a first-class consumer and its wiring is a separate repository pull request. Upstream provider keys remain in the orchestrator credential KV. ### GitHub Actions runner lifetime @@ -58,13 +58,13 @@ The original two-job design executed `npm run release:verify` and later minted t ### Read-only model job -The model runs in `propose_product_increment`, which has only repository and pull-request read permissions. Its subprocess receives `NVIDIA_API_KEY` but removes GitHub tokens, Actions OIDC credentials, artifact/cache runtime tokens, and runner command-file paths. OpenCode command permissions deny common network and repository-mutation tools. +The model runs in `propose_product_increment`, which has only repository and pull-request read permissions. Its subprocess receives `NOEMA_LLM_API_KEY` but removes GitHub tokens, Actions OIDC credentials, artifact/cache runtime tokens, and runner command-file paths. OpenCode command permissions deny common network and repository-mutation tools. The successful working tree becomes a binary full-index `proposal.patch` bound to its exact base SHA, SHA-256, changed-file count, and byte count. Symlink mode `120000` and gitlink mode `160000` are rejected before artifact upload. The artifact expires after one day, cannot be overwritten under the same name, and exports exact ID and archive digest outputs. ### Fresh uncredentialed verifier -`package_product_increment` runs on a fresh runner and receives no NIM or Maintainer credential. Its job-level token is read-only. It downloads the artifact by exact ID and verifies the artifact REST object's ID, deterministic name, expiry state, originating workflow run, and digest. It separately verifies patch SHA-256, byte count, changed-file count, exact base, and forbidden Git modes. +`package_product_increment` runs on a fresh runner and receives no gateway or Maintainer credential. Its job-level token is read-only. It downloads the artifact by exact ID and verifies the artifact REST object's ID, deterministic name, expiry state, originating workflow run, and digest. It separately verifies patch SHA-256, byte count, changed-file count, exact base, and forbidden Git modes. The verifier then applies the patch and executes `npm run release:verify` with GitHub, OIDC, Actions runtime/cache, and runner command-file credentials removed, dependency lifecycle scripts disabled, and an isolated temporary home. It fails if verification mutates tracked or non-ignored untracked files or changes the staged patch digest. @@ -72,7 +72,7 @@ This job deliberately executes untrusted proposed code, but no publication crede ### Fresh non-executing publisher -`publish_product_increment` depends on successful proposal and verification jobs. It starts on a third fresh runner with a read-only job token and no NIM credential. It does not install dependencies or run proposed tests, builds, package scripts, binaries, or shell commands. It is the only fresh write-capable runner, and it remains read-only until the late-bound Maintainer App token is minted. +`publish_product_increment` depends on successful proposal and verification jobs. It starts on a third fresh runner with a read-only job token and no gateway credential. It does not install dependencies or run proposed tests, builds, package scripts, binaries, or shell commands. It is the only fresh write-capable runner, and it remains read-only until the late-bound Maintainer App token is minted. Before applying the proposal, the publisher copies the trusted base-branch metadata parser into `RUNNER_TEMP`. It downloads the exact same artifact ID and repeats artifact/run/digest and patch/base/file/byte/mode validation. It applies the patch only as data, then uses the preserved parser to transform bounded `PR_MESSAGE.md` input. @@ -88,13 +88,13 @@ GitHub provides no atomic “create a PR only if none exists” transaction. The ### Dedicated development and publication credentials -The workflow maps `secrets.NVIDIA_NIM_API_KEY` to `NVIDIA_API_KEY` only in the model step. It does not use GitHub Copilot, GitHub Models, `NOEMA_LLM_API_KEY`, the reviewer App private key, or production `contextual-orchestrator` reviewer credentials. Reviewer credential names and routing remain unchanged. +The workflow maps `secrets.NOEMA_LLM_API_KEY` and `vars.NOEMA_LLM_API_URL` through the same gateway contract as production review. It does not use GitHub Copilot, GitHub Models, NVIDIA NIM, Bytez, OpenRouter, or OpenAI provider keys. The reviewer App private key and `/exchange` OIDC broker remain unchanged. Sequential model-candidate failover is forbidden; the orchestrator selects min-cost / max-performance. Publication reuses the repository's existing dedicated Maintainer App variables and private-key secret. It does not repurpose the reviewer App identity or key contract. The separation preserves independent review evidence and gives generated PRs a normal event path into `ci`, `reviewer-ci`, and Security Scan. -### Clean model fallback +### Single gateway session -Each model candidate has a timeout of 900 seconds plus a 30-second forced-termination grace period. Cleanup and dependency reinstall occur only between candidates, so three candidate budgets and two bounded reinstalls use `3 × (900 + 30) + 2 × (60 + 10) = 2,930 seconds`. Reserving 300 seconds for setup and the final diagnostic yields 3,230 seconds inside the 3,300-second proposal-job limit, leaving 70 seconds of explicit slack. After the final candidate fails, no later candidate can be protected by cleanup, so the workflow skips reset, clean, and reinstall and emits the stable all-candidates-failed diagnostic directly. Partial output from one model cannot contaminate a later candidate. Fallback improves availability; it is not quality evidence. +The workflow runs exactly one OpenCode session against the `contextual-orchestrator` routing alias. The session budget is 2,700 seconds plus a 30-second forced-termination grace period. Reserving 300 seconds for setup and the final diagnostic yields 3,030 seconds inside the 3,300-second proposal-job limit, leaving 270 seconds of explicit slack. If that session fails, Noema does not try the next model or agent. Provider failover, allowlists, budgets, and circuit breakers stay in the gateway. ### Executable product contract @@ -108,19 +108,19 @@ The proposal is limited to 40 changed files and 500,000 patch bytes. Symlinks, g ## Residual risks -### NIM credential exposure within the model process +### Gateway credential exposure within the model process -The NIM key necessarily exists in the OpenCode process. Command denials are defense in depth, not a microVM egress boundary. A shell-capable process may construct behavior equivalent to a denied command. The security claim is deliberately narrower: the key is development-only, GitHub write credentials are absent, and model output crosses jobs only as a bounded immutable artifact. +The dedicated `NOEMA_LLM_API_KEY` necessarily exists in the OpenCode process. Command denials are defense in depth, not a microVM egress boundary. A shell-capable process may construct behavior equivalent to a denied command. The security claim is deliberately narrower: the key is a gateway inference token rather than an upstream provider key, GitHub write credentials are absent, and model output crosses jobs only as a bounded immutable artifact. -A future stronger design should broker inference through a narrow proxy and keep the upstream NIM credential outside the model process. +Upstream NVIDIA, Bytez, OpenRouter, and OpenAI credentials remain in the orchestrator KV and are not present in this repository or the OpenCode subprocess. ### Repository data processing -OpenCode can send prompts and selected repository context to NVIDIA NIM. Operators must evaluate confidentiality, retention, regional, contractual, and data-processing requirements before enabling the secret. Production logs, customer evidence, reviewer secrets, deployment credentials, and revenue evidence are not intentionally provided, but committed repository content is readable. +OpenCode can send prompts and selected repository context to `contextual-orchestrator`. Operators must evaluate confidentiality, retention, regional, contractual, and data-processing requirements before enabling the secret. Production logs, customer evidence, reviewer secrets, deployment credentials, and revenue evidence are not intentionally provided, but committed repository content is readable. ### Untrusted executable verification -`npm run release:verify` executes proposed code. The verifier therefore has no Maintainer App secret, App token, NIM secret, GitHub write token, OIDC credential, Actions runtime/cache credential, or runner command-file channel. A fresh publisher starts only after the verifier completes successfully. This is materially stronger than same-job environment cleanup, but the verifier is still not a hostile-code microVM and can affect only its own ephemeral runner and outbound network accessible under GitHub-hosted runner policy. +`npm run release:verify` executes proposed code. The verifier therefore has no Maintainer App secret, App token, gateway secret, GitHub write token, OIDC credential, Actions runtime/cache credential, or runner command-file channel. A fresh publisher starts only after the verifier completes successfully. This is materially stronger than same-job environment cleanup, but the verifier is still not a hostile-code microVM and can affect only its own ephemeral runner and outbound network accessible under GitHub-hosted runner policy. ### Artifact service trust @@ -132,19 +132,19 @@ The Maintainer App token is short-lived and repository-scoped, but the App regis ### Model and scheduler instability -Hosted model availability, quotas, latency, tool behavior, and quality can change. GitHub schedules can be delayed, dropped, or disabled. Candidate success and workflow completion are not semantic quality guarantees. The system safely produces either no PR or one reviewable PR; it never self-approves or self-merges. +Hosted gateway availability, quotas, latency, tool behavior, and quality can change. GitHub schedules can be delayed, dropped, or disabled. A completed session is not semantic quality evidence. The system safely produces either no PR or one reviewable PR; it never self-approves or self-merges. ## Verification mapping | Requirement or risk | Executable control | |---|---| | Existing PR | Read-only inventory gate before model and repeated gate before push | -| Missing NIM key | `nim_api_key_unavailable`, no model call | +| Missing gateway URL or key | `orchestrator_gateway_unavailable`, no model call | | Mutable OpenCode binary | Exact version, official archive, SHA-256 verification | | Reviewer-key reuse | Negative workflow assertions and dedicated secret mapping | | Model repository mutation | Read-only job; no GitHub write token; mutation commands denied | | Runner command-channel poisoning | Command-file and Actions runtime variables removed | -| Candidate contamination | Hard reset, `git clean -fdx`, clean reinstall | +| Sequential model failover | Single routing alias; no candidate list or inter-model reset | | Artifact substitution | Exact artifact ID, name, workflow-run ID, archive digest, patch digest, exact base, file count, and byte count | | Unverified proposal | Complete release verification in model job and fresh verifier | | Verification mutation | Unstaged/untracked check and post-verification digest match | @@ -178,9 +178,7 @@ GitHub. (2026). *Create GitHub App token*. GitHub Marketplace. Retrieved August GitHub. (2026). *Upload GitHub Actions artifacts*. GitHub. Retrieved August 5, 2026, from https://github.com/actions/upload-artifact -NVIDIA Corporation. (2026). *API reference—NVIDIA NIM for large language models*. NVIDIA Documentation. Retrieved August 5, 2026, from https://docs.nvidia.com/nim/large-language-models/latest/api-reference.html - -NVIDIA Corporation. (2026). *Tool calling and MCP integration*. NVIDIA Documentation. Retrieved August 5, 2026, from https://docs.nvidia.com/nim/large-language-models/2.0.2/advanced-use-cases/tool-calling-and-mcp.html +OpenAI. (2026). *Chat Completions*. OpenAI API Documentation. Retrieved August 16, 2026, from https://platform.openai.com/docs/api-reference/chat OpenCode. (2026). *CLI*. Retrieved August 5, 2026, from https://opencode.ai/docs/cli/ diff --git a/docs/doctoring/hourly-product-development-prerequisites.md b/docs/doctoring/hourly-product-development-prerequisites.md index 80c698df6..24be3b396 100644 --- a/docs/doctoring/hourly-product-development-prerequisites.md +++ b/docs/doctoring/hourly-product-development-prerequisites.md @@ -8,14 +8,14 @@ This doctoring note uses APA 7 reference form. It separates source-supported fac The scheduled development path has two independent credential prerequisites: -1. `NVIDIA_NIM_API_KEY` permits the read-only OpenCode proposal job to obtain model inference. +1. `NOEMA_LLM_API_URL` and `NOEMA_LLM_API_KEY` permit the read-only OpenCode proposal job to reach the `contextual-orchestrator` gateway. 2. `NOEMA_MAINTAINER_APP_CLIENT_ID` and `NOEMA_MAINTAINER_APP_PRIVATE_KEY` permit the later non-executing publisher to create one repository-scoped branch and pull request. -Checking only the inference key can spend model compute on a proposal that the workflow is structurally unable to publish. That is a deterministic configuration failure rather than a model-quality failure and should be rejected before checkout or inference. +Checking only the inference token can spend model compute on a proposal that the workflow is structurally unable to publish. That is a deterministic configuration failure rather than a model-quality failure and should be rejected before checkout or inference. ## Source-supported controls -GitHub documents that a workflow reads a secret only when the workflow explicitly includes it, and recommends granting credentials the minimum possible permissions. GitHub further recommends GitHub Apps as fine-grained, short-lived, non-user-bound credentials when repository automation needs permissions beyond read-only access. These facts support separating the NIM development credential from the repository publication credential and preserving read-only job-level `GITHUB_TOKEN` permissions. This is a least privilege control: model execution never receives publication authority, and publication receives only the repository-scoped permissions required to create one branch and pull request. +GitHub documents that a workflow reads a secret only when the workflow explicitly includes it, and recommends granting credentials the minimum possible permissions. GitHub further recommends GitHub Apps as fine-grained, short-lived, non-user-bound credentials when repository automation needs permissions beyond read-only access. These facts support separating the gateway inference token from the repository publication credential and preserving read-only job-level `GITHUB_TOKEN` permissions. This is a least privilege control: model execution never receives publication authority, and publication receives only the repository-scoped permissions required to create one branch and pull request. NIST SP 800-218 Version 1.1 recommends integrating secure-development requirements and verification into the software life cycle. NIST SP 800-218A augments that framework with practices specific to generative AI and foundation-model systems. The December 2025 SP 800-218 Revision 1 initial public draft describes updated secure and reliable development practices, but remains a draft; Noema therefore records it as a current informative source while retaining the final Version 1.1 and final AI community profile as the normative published references. @@ -23,19 +23,20 @@ NIST SP 800-218 Version 1.1 recommends integrating secure-development requiremen Before OpenCode starts, the proposal gate evaluates only presence booleans: -- `NVIDIA_NIM_API_KEY != ''` +- `NOEMA_LLM_API_KEY != ''` +- `NOEMA_LLM_API_URL != ''` - `NOEMA_MAINTAINER_APP_CLIENT_ID != ''` - `NOEMA_MAINTAINER_APP_PRIVATE_KEY != ''` -The workflow does not reveal values, import the private key, mint an App token, or call a model during this gate. Missing publication configuration returns the stable reason `maintainer_app_unavailable` and stops before checkout, dependency installation, OpenCode download, or NVIDIA inference. +The workflow does not reveal values, import the private key, mint an App token, or call a model during this gate. Missing publication configuration returns the stable reason `maintainer_app_unavailable` and stops before checkout, dependency installation, OpenCode download, or gateway inference. Missing gateway configuration returns `orchestrator_gateway_unavailable`. The App token is still minted only in the third, non-executing publication job. Presence checking does not prove that the key is valid, that the App remains installed, or that permissions are sufficient; those live failures continue to fail closed when `actions/create-github-app-token` runs. This preserves the late-token trust boundary while preventing known-impossible sessions. Manual `dry_run` deliberately bypasses credential-presence requirements because it performs no checkout, model call, artifact publication, branch push, or pull-request creation. It remains an operator inspection path rather than evidence that a live proposal can be published. -## Reviewer credential separation +## Gateway contract, not provider keys -The gate does not read, rename, or validate reviewer credentials. In particular, it does not use `NOEMA_LLM_API_KEY`, the reviewer App private key, or `contextual-orchestrator` provider credentials. Development proposal authority, publication authority, and independent review authority remain separate. +The gate uses the same dedicated gateway names as production review: `NOEMA_LLM_API_URL`, `NOEMA_LLM_MODEL`, and `NOEMA_LLM_API_KEY`. It does not read `NVIDIA_NIM_API_KEY`, `BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, or `OPENAI_API_KEY`. Development proposal authority, publication authority, and independent review App identity remain separate even though both LLM jobs share the orchestrator contract. ## Verification contract @@ -43,9 +44,10 @@ Executable tests must prove that: - both Maintainer App presence booleans are evaluated in the pre-inference gate; - either missing value produces `dispatch=false` and `reason=maintainer_app_unavailable`; +- missing gateway URL or key produces `orchestrator_gateway_unavailable`; - the gate appears before task preparation, checkout, and OpenCode execution; - `dry_run=true` remains available without production credentials; -- the dedicated NIM secret and reviewer credential boundaries remain unchanged; and +- the dedicated gateway token and reviewer App identity remain separate; and - operations and doctoring documents describe the same failure reason and credential names. ## Residual risk diff --git a/docs/library-boundary-decision.md b/docs/library-boundary-decision.md index 7717e4627..5d1208ddf 100644 --- a/docs/library-boundary-decision.md +++ b/docs/library-boundary-decision.md @@ -39,5 +39,6 @@ Submodule은 별도 법인, 별도 라이선스, 별도 release cadence가 확 ## Current Action - `src/index.ts`를 지금 즉시 쪼개지 않는다. +- naruon 판단·결정 에이전트는 OIDC/`/exchange` core가 아니라 LLM 게이트웨이 계약만 재사용한다. 그 계약은 npm package가 아니라 `contracts/orchestrator-gateway.json`과 `scripts/lib/orchestrator-gateway.mjs` 검증 함수로 공개한다. naruon 배선은 별도 저장소 PR이다. - 20억 매각 readiness는 `docs/acquisition-readiness-2b.md`와 `npm run acquisition:audit`로 추적한다. - core package 분리는 위 trigger가 발생한 뒤 test-first로 진행한다. diff --git a/docs/operations/hourly-product-development-prerequisites.md b/docs/operations/hourly-product-development-prerequisites.md index 5b00164d2..cd27747aa 100644 --- a/docs/operations/hourly-product-development-prerequisites.md +++ b/docs/operations/hourly-product-development-prerequisites.md @@ -2,42 +2,45 @@ ## 목적 -`.github/workflows/hourly-product-development.yml`은 OpenCode가 NVIDIA NIM을 호출하기 전에 모델이 만든 제안을 실제 pull request로 게시할 수 있는지 먼저 확인합니다. 게시 경로가 준비되지 않은 상태에서 추론 비용만 소비하고 마지막 단계에서 실패하는 동작을 허용하지 않습니다. +`.github/workflows/hourly-product-development.yml`은 OpenCode가 `contextual-orchestrator`를 호출하기 전에 모델이 만든 제안을 실제 pull request로 게시할 수 있는지 먼저 확인합니다. 게시 경로가 준비되지 않은 상태에서 추론 비용만 소비하고 마지막 단계에서 실패하는 동작을 허용하지 않습니다. ## 필수 입력 일반 실행에는 다음 값이 모두 필요합니다. -- `NVIDIA_NIM_API_KEY`: OpenCode 개발 세션 전용 조직 또는 저장소 secret +- `NOEMA_LLM_API_URL`: `/v1`로 끝나는 HTTPS `contextual-orchestrator` 주소 +- `NOEMA_LLM_API_KEY`: 전용 게이트웨이 추론 토큰. 상위 공급자 키가 아님 +- `NOEMA_LLM_MODEL`: 보통 라우팅 별칭 `contextual-orchestrator` - `NOEMA_MAINTAINER_APP_CLIENT_ID`: `ContextualWisdomLab/noema`에만 설치된 Maintainer GitHub App의 repository variable - `NOEMA_MAINTAINER_APP_PRIVATE_KEY`: 같은 App의 private-key secret -기존 reviewer App, `NOEMA_LLM_API_KEY`, `contextual-orchestrator` reviewer credential은 이 전제조건에 사용하지 않으며 이름과 권한 경계를 변경하지 않습니다. +리뷰어 App 신원과 OIDC 토큰 중개, 샌드박스 경계는 이 전제조건에서 변경하지 않습니다. 개발과 리뷰는 같은 게이트웨이 계약을 쓰지만 Maintainer App과 Reviewer App 자격 증명은 분리되어 있습니다. ## 실패 폐쇄 동작 -열린 pull request가 없더라도 Maintainer App의 client ID 또는 private key가 없으면 gate는 다음 결과를 기록하고 checkout·OpenCode 다운로드·NVIDIA 호출 전에 종료합니다. +열린 pull request가 없더라도 Maintainer App의 client ID 또는 private key가 없으면 gate는 다음 결과를 기록하고 checkout·OpenCode 다운로드·게이트웨이 호출 전에 종료합니다. ```text dispatch=false reason=maintainer_app_unavailable ``` -`NVIDIA_NIM_API_KEY`가 없으면 기존 `nim_api_key_unavailable` 결과를 유지합니다. pull request inventory를 읽지 못하거나 열린 PR이 있으면 각각 `pull_request_inventory_unavailable`, `open_pull_request`로 종료합니다. +`NOEMA_LLM_API_KEY` 또는 `NOEMA_LLM_API_URL`이 없으면 `orchestrator_gateway_unavailable`로 종료합니다. pull request inventory를 읽지 못하거나 열린 PR이 있으면 각각 `pull_request_inventory_unavailable`, `open_pull_request`로 종료합니다. ## dry_run -`workflow_dispatch`에서 `dry_run=true`를 선택하면 secret이나 App credential이 없어도 queue gate와 전체 task contract를 검토할 수 있습니다. dry run은 checkout, OpenCode 설치, NVIDIA API 호출, artifact 업로드, branch push, pull request 생성 중 어느 것도 수행하지 않습니다. +`workflow_dispatch`에서 `dry_run=true`를 선택하면 secret이나 App credential이 없어도 queue gate와 전체 task contract를 검토할 수 있습니다. dry run은 checkout, OpenCode 설치, 게이트웨이 호출, artifact 업로드, branch push, pull request 생성 중 어느 것도 수행하지 않습니다. ## 활성화 확인 1. Maintainer App이 `ContextualWisdomLab/noema`에만 설치되어 있는지 확인합니다. 2. App 권한을 Metadata read, Contents write, Pull requests write로 제한합니다. 3. `NOEMA_MAINTAINER_APP_CLIENT_ID`와 `NOEMA_MAINTAINER_APP_PRIVATE_KEY`를 설정합니다. -4. `dry_run=true`로 prompt와 queue 판단을 검토합니다. -5. 임시 검증 PR에서 publication job이 짧은 수명의 repository-scoped token을 생성하고 정확히 한 branch와 한 PR만 만드는지 확인합니다. -6. reviewer credential 이름이나 central review route가 변경되지 않았는지 확인합니다. +4. 리뷰와 동일한 `NOEMA_LLM_API_URL`, `NOEMA_LLM_MODEL`, `NOEMA_LLM_API_KEY`를 설정합니다. +5. `dry_run=true`로 prompt와 queue 판단을 검토합니다. +6. 임시 검증 PR에서 publication job이 짧은 수명의 repository-scoped token을 생성하고 정확히 한 branch와 한 PR만 만드는지 확인합니다. +7. 리뷰어 App 신원이나 `/exchange` OIDC 경계가 변경되지 않았는지 확인합니다. ## 운영 복구 -`maintainer_app_unavailable`이 나타나면 모델 fallback이나 timeout을 조정하지 않습니다. App 설치 범위, client ID variable, private-key secret과 key rotation 상태를 복구한 뒤 다시 실행합니다. 의도적인 중지는 workflow를 비활성화하거나 App credential을 회수하여 수행합니다. +`maintainer_app_unavailable`이 나타나면 모델이나 timeout을 조정하지 않습니다. App 설치 범위, client ID variable, private-key secret과 key rotation 상태를 복구한 뒤 다시 실행합니다. `orchestrator_gateway_unavailable`이면 게이트웨이 URL, 전용 추론 토큰, `/healthz` 신원을 복구합니다. 의도적인 중지는 workflow를 비활성화하거나 App credential 또는 게이트웨이 토큰을 회수하여 수행합니다. diff --git a/docs/operations/hourly-product-development.md b/docs/operations/hourly-product-development.md index 114681605..56331c13b 100644 --- a/docs/operations/hourly-product-development.md +++ b/docs/operations/hourly-product-development.md @@ -1,24 +1,26 @@ -# 시간별 NVIDIA NIM 제품 개발 운영 +# 시간별 contextual-orchestrator 제품 개발 운영 ## 목적과 책임 경계 -`.github/workflows/hourly-product-development.yml`은 **열린 PR 0개** 상태에서만 Noema의 다음 구매자 가시적 제품 증분을 제안합니다. OpenCode 1.17.13과 전용 `NVIDIA_NIM_API_KEY`를 사용하지만 리뷰, 승인, 병합, 릴리스, 배포는 수행하지 않습니다. 정확한 현재 HEAD의 리뷰, 필수 Checks, 미해결 스레드, 저장소 규칙, 병합 가능성 판단은 기존 `hourly-commercial-readiness`가 계속 담당합니다. 자동 개발은 후보 PR을 만드는 역할만 하며 최종 거버넌스 권한을 획득하지 않습니다. +`.github/workflows/hourly-product-development.yml`은 **열린 PR 0개** 상태에서만 Noema의 다음 구매자 가시적 제품 증분을 제안합니다. OpenCode 1.17.13은 코딩 에이전트로만 남고, 모델 호출은 리뷰와 같은 `contextual-orchestrator` 게이트웨이 계약을 사용합니다. 리뷰, 승인, 병합, 릴리스, 배포는 수행하지 않습니다. 정확한 현재 HEAD의 리뷰, 필수 Checks, 미해결 스레드, 저장소 규칙, 병합 가능성 판단은 기존 `hourly-commercial-readiness`가 계속 담당합니다. 자동 개발은 후보 PR을 만드는 역할만 하며 최종 거버넌스 권한을 획득하지 않습니다. -워크플로는 매시 47분에 실행되고 수동 `dry_run=true`를 지원합니다. 드라이 런은 실제 PR 목록과 작업 계약만 확인하며 checkout, 모델 호출, 아티팩트 업로드, 브랜치 push, PR 생성을 하지 않습니다. GitHub 예약 실행은 정시 SLA가 아니므로 각 실행은 이전 상태를 믿지 않고 열린 PR 목록, 기본 브랜치 SHA, 필요한 자격 증명을 다시 확인합니다. 목록 조회 실패, 기존 PR 발견, 시크릿 부재는 모두 실패 폐쇄 사유입니다. +워크플로는 매시 47분에 실행되고 수동 `dry_run=true`를 지원합니다. 드라이 런은 실제 PR 목록과 작업 계약만 확인하며 checkout, 모델 호출, 아티팩트 업로드, 브랜치 push, PR 생성을 하지 않습니다. GitHub 예약 실행은 정시 SLA가 아니므로 각 실행은 이전 상태를 믿지 않고 열린 PR 목록, 기본 브랜치 SHA, 필요한 자격 증명을 다시 확인합니다. 목록 조회 실패, 기존 PR 발견, 게이트웨이 부재는 모두 실패 폐쇄 사유입니다. -## 모델 폴백과 시간 예산 +## 게이트웨이 계약과 시간 예산 -공식 OpenCode 아카이브는 고정 버전과 SHA-256으로 검증합니다. 공급자는 NVIDIA NIM 엔드포인트만 허용하고 공유, 자동 업데이트, MCP, LSP, 외부 디렉터리, 하위 에이전트, 질문 도구, 웹 검색과 웹 가져오기를 비활성화합니다. 모델 후보는 Llama Nemotron, Nemotron Super, DeepSeek 순서로 시도합니다. **후보별 900초**와 강제 종료 유예 30초를 적용합니다. 다음 후보가 남아 있을 때만 의존성 재설치를 60초와 강제 종료 유예 10초로 별도 제한합니다. 최악의 경우 세 후보 실행·종료에 `3 × (900 + 30) = 2,790초`, 후보 사이 두 번의 재설치에 `2 × (60 + 10) = 140초`를 사용합니다. 최초 설정과 최종 진단에 300초를 예약하면 총 3,230초이며, 3,300초인 55분 제안 job 예산 안에 70초의 명시적 여유를 남깁니다. +공식 OpenCode 아카이브는 고정 버전과 SHA-256으로 검증합니다. 공급자는 `contextual-orchestrator` 한 곳만 허용합니다. `NOEMA_LLM_API_URL`은 `/v1`로 끝나는 HTTPS OpenAI 호환 주소여야 하고, `NOEMA_LLM_MODEL`은 보통 라우팅 별칭 `contextual-orchestrator`이며, `NOEMA_LLM_API_KEY`는 전용 게이트웨이 추론 토큰입니다. 상위 공급자 키(`NVIDIA_NIM_API_KEY`, `NVIDIA_NIM_API_KEY_SUB`, `BYTEZ_API_KEY`, `OPENROUTER_API_KEY`, `OPENAI_API_KEY`)는 오케스트레이터 KV에만 두고 Noema 런타임에 넣지 않습니다. -다음 후보가 남아 있는 상태에서 후보가 실패하면 `git reset --hard HEAD`, `git clean -fdx`를 실행하고 `npm ci --ignore-scripts`를 명시적 60초 제한 안에서 재실행한 뒤에만 폴백합니다. 마지막 후보 실패에는 다음 후보가 없으므로 reset·clean·재설치를 실행하지 않고 곧바로 안정적인 전체 후보 실패 진단으로 종료합니다. 후보 사이 재설치가 실패하거나 제한 시간을 넘기면 의존성 트리가 완전하다고 증명할 수 없으므로 즉시 실패 폐쇄하고 이후 모델 후보를 실행하지 않습니다. 이전 후보의 부분 변경이나 생성 파일이 다음 후보의 입력을 오염시키지 않도록 하며, 폴백은 가용성 제어일 뿐 품질 증거가 아닙니다. 성공한 제안도 독립 검증과 리뷰를 통과해야 합니다. +Noema는 모델 후보를 순서대로 시도하지 않습니다. 최소 비용과 최대 성능 선택은 오케스트레이터의 책임입니다. 직접 NVIDIA NIM, OpenAI, GitHub Models, OpenRouter, Bytez 호스트로 폴백하지 않습니다. 세션은 **한 번**이며 2,700초와 강제 종료 유예 30초를 적용합니다. 최초 설정과 최종 진단에 300초를 예약하면 총 3,030초이며, 3,300초인 55분 제안 job 예산 안에 270초의 명시적 여유를 남깁니다. 세션이 실패하면 다음 모델을 고르지 않고 안정적인 실패 진단으로 종료합니다. + +공유 스크립트 `scripts/verify-orchestrator-gateway.mjs`가 리뷰와 동일한 사전 점검을 수행합니다. 인증 없이 `/healthz`가 `service=contextual-orchestrator`를 반환해야 하며, 알려진 직접 공급자 호스트는 거부합니다. 같은 계약은 `contracts/orchestrator-gateway.json`으로 공개되며 `ContextualWisdomLab/naruon`의 판단·결정 에이전트도 1급 소비자입니다. naruon 배선은 이 저장소가 아니라 별도 PR에서 합니다. ## 세 runner의 자격 증명 분리 -첫 번째 제안 runner는 읽기 권한만 가지며 OpenCode subprocess에는 NVIDIA 키만 전달합니다. GitHub 토큰, OIDC 값, Actions 런타임 토큰, 캐시 토큰, runner 명령 파일 채널을 제거합니다. 변경은 40개 파일과 500,000바이트로 제한하고 공백 오류, 심링크 모드 `120000`, gitlink 모드 `160000`을 원본 모드와 대상 모드 양쪽에서 검사합니다. 결과는 정확한 base SHA, 파일 수, 바이트 수, SHA-256에 결합된 binary full-index `proposal.patch`로 저장합니다. +첫 번째 제안 runner는 읽기 권한만 가지며 OpenCode subprocess에는 게이트웨이 추론 토큰만 전달합니다. GitHub 토큰, OIDC 값, Actions 런타임 토큰, 캐시 토큰, runner 명령 파일 채널을 제거합니다. 변경은 40개 파일과 500,000바이트로 제한하고 공백 오류, 심링크 모드 `120000`, gitlink 모드 `160000`을 원본 모드와 대상 모드 양쪽에서 검사합니다. 결과는 정확한 base SHA, 파일 수, 바이트 수, SHA-256에 결합된 binary full-index `proposal.patch`로 저장합니다. -두 번째 검증 runner는 NIM 키와 Maintainer App 키가 없는 새 실행기입니다. `actions: read`, `contents: read`, `pull-requests: read`만 사용합니다. artifact ID, 이름, 만료 여부, 원본 workflow run, digest, patch 크기와 해시, base SHA를 독립적으로 확인합니다. 패치를 적용한 뒤 격리된 임시 홈과 제거된 GitHub·OIDC·Actions 채널에서 `npm run release:verify`를 실행하고 검증 전후 staged patch digest가 동일한지 확인합니다. 이 runner는 제안 코드를 실행하지만 게시 권한을 받지 않습니다. +두 번째 검증 runner는 게이트웨이 키와 Maintainer App 키가 없는 새 실행기입니다. `actions: read`, `contents: read`, `pull-requests: read`만 사용합니다. artifact ID, 이름, 만료 여부, 원본 workflow run, digest, patch 크기와 해시, base SHA를 독립적으로 확인합니다. 패치를 적용한 뒤 격리된 임시 홈과 제거된 GitHub·OIDC·Actions 채널에서 `npm run release:verify`를 실행하고 검증 전후 staged patch digest가 동일한지 확인합니다. 이 runner는 제안 코드를 실행하지만 게시 권한을 받지 않습니다. -`publish_product_increment`는 **세 번째 새 게시 runner**입니다. 제안 코드를 실행하지 않고 NIM 키도 받지 않습니다. 기본 브랜치에서 신뢰된 PR 메타데이터 파서를 먼저 복사한 뒤 동일한 artifact ID와 digest-bound patch를 다시 검증합니다. 그 다음에만 full SHA로 고정된 액션이 짧은 수명의 Maintainer App 토큰을 발급합니다. 토큰 범위는 Noema 저장소의 metadata read, contents write, pull-request write로 제한됩니다. App 토큰 발급 후에도 열린 PR 큐와 실제 `main` SHA를 다시 읽고, 새 PR이나 base 전진이 있으면 원격 변경 전에 종료합니다. +`publish_product_increment`는 **세 번째 새 게시 runner**입니다. 제안 코드를 실행하지 않고 게이트웨이 키도 받지 않습니다. 기본 브랜치에서 신뢰된 PR 메타데이터 파서를 먼저 복사한 뒤 동일한 artifact ID와 digest-bound patch를 다시 검증합니다. 그 다음에만 full SHA로 고정된 액션이 짧은 수명의 Maintainer App 토큰을 발급합니다. 토큰 범위는 Noema 저장소의 metadata read, contents write, pull-request write로 제한됩니다. App 토큰 발급 후에도 열린 PR 큐와 실제 `main` SHA를 다시 읽고, 새 PR이나 base 전진이 있으면 원격 변경 전에 종료합니다. ## 신뢰할 수 없는 입력과 게시 @@ -28,6 +30,6 @@ ## 운영 위험과 롤백 -NIM 키는 OpenCode 프로세스 안에 존재하므로 명령 거부만으로 microVM egress 경계를 주장하지 않습니다. 지원 가능한 주장은 모델과 쓰기 가능한 저장소 토큰이 공존하지 않고, 신뢰할 수 없는 코드는 게시 자격 증명이 없는 runner에서만 실행되며, 게시 runner는 동일한 immutable patch를 실행 없이 재구성한다는 것입니다. OpenCode는 commit된 저장소 문맥을 외부 모델 서비스로 보낼 수 있으므로 기밀성, 데이터 보존, 지역, 계약 요건을 별도로 평가해야 합니다. +게이트웨이 토큰은 OpenCode 프로세스 안에 존재하므로 명령 거부만으로 microVM egress 경계를 주장하지 않습니다. 지원 가능한 주장은 모델과 쓰기 가능한 저장소 토큰이 공존하지 않고, 신뢰할 수 없는 코드는 게시 자격 증명이 없는 runner에서만 실행되며, 게시 runner는 동일한 immutable patch를 실행 없이 재구성한다는 것입니다. OpenCode는 commit된 저장소 문맥을 오케스트레이터로 보낼 수 있으므로 기밀성, 데이터 보존, 지역, 계약 요건을 별도로 평가해야 합니다. 상위 공급자 선택, 허용 목록, 예산, 회로 차단, 감사는 오케스트레이터에 남습니다. -GitHub에는 다른 PR이 없을 때만 PR을 생성하는 원자적 트랜잭션이 없습니다. 최종 큐와 base 재검증, 고유 브랜치 이름, branch protection, exact-head 리뷰가 남은 경쟁 위험을 통제합니다. 모델 실행을 중지하려면 워크플로를 비활성화하거나 `NVIDIA_NIM_API_KEY`를 폐기합니다. 게시만 중지하려면 Maintainer App 키를 폐기합니다. `main`에서 워크플로를 제거하는 것이 코드 롤백이며 기존 `/exchange`, 리뷰, 릴리스, 배포 경로에는 영향을 주지 않습니다. +GitHub에는 다른 PR이 없을 때만 PR을 생성하는 원자적 트랜잭션이 없습니다. 최종 큐와 base 재검증, 고유 브랜치 이름, branch protection, exact-head 리뷰가 남은 경쟁 위험을 통제합니다. 모델 실행을 중지하려면 워크플로를 비활성화하거나 `NOEMA_LLM_API_KEY`를 폐기합니다. 게시만 중지하려면 Maintainer App 키를 폐기합니다. `main`에서 워크플로를 제거하는 것이 코드 롤백이며 기존 `/exchange`, 리뷰, 릴리스, 배포 경로에는 영향을 주지 않습니다. diff --git a/docs/orchestrator-gateway-consumer-contract.md b/docs/orchestrator-gateway-consumer-contract.md new file mode 100644 index 000000000..71027ebf9 --- /dev/null +++ b/docs/orchestrator-gateway-consumer-contract.md @@ -0,0 +1,67 @@ +# Orchestrator gateway consumer contract + +This is the reusable Noema-side LLM contract. Every first-class consumer — +Noema GitHub review, Noema hourly product development, and +`ContextualWisdomLab/naruon` judgments and decisions — must call +`ContextualWisdomLab/contextual-orchestrator` through the same interface. + +naruon wiring is a **separate repository pull request**. This document does +not implement naruon. It publishes the contract naruon must import or copy. + +The machine-readable copy is [`contracts/orchestrator-gateway.json`](../contracts/orchestrator-gateway.json). +Print it without secrets: + +```bash +node scripts/verify-orchestrator-gateway.mjs --print-contract +``` + +Reusable validation lives in `scripts/lib/orchestrator-gateway.mjs` +(`parseOrchestratorGatewayUrl`, `resolveOrchestratorModel`, +`requireOrchestratorApiKey`, `verifyOrchestratorHealthz`, +`orchestratorGatewayConsumerContract`). The OpenCode config writer in the +same module is Noema-only. Do not clone an OpenCode sidecar into naruon. + +## Required settings + +| Name | Meaning | +| --- | --- | +| `NOEMA_LLM_API_URL` | HTTPS OpenAI-compatible base ending in `/v1`. No userinfo, query, or fragment. | +| `NOEMA_LLM_MODEL` | One routing alias. Production default is `contextual-orchestrator`. | +| `NOEMA_LLM_API_KEY` | Dedicated gateway inference token. Never an upstream provider key. | + +`GET /healthz` is unauthenticated and must return +`{"status":"ok","service":"contextual-orchestrator"}`. + +At request time, secrets come from a KV / credential registry (the Worker +`Env` binding in Noema; naruon must use its own KV-equivalent). Process +environment is transport into that registry only. + +## Forbidden + +- Sequential model or agent candidate lists inside the consumer +- Direct provider hosts: `api.openai.com`, `models.github.ai`, + `openrouter.ai`, `integrate.api.nvidia.com`, `api.nvidia.com`, + `api.bytez.com` +- Provider keys in the consumer runtime or repository: + `NVIDIA_NIM_API_KEY`, `NVIDIA_NIM_API_KEY_SUB`, `BYTEZ_API_KEY`, + `OPENROUTER_API_KEY`, `OPENAI_API_KEY` +- `COPILOT_GITHUB_TOKEN` + +The orchestrator selects min-cost / max-performance. Provider failover, +allowlists, budgets, circuit breakers, and audit stay in the gateway. + +## First-class consumers + +| Consumer | Repository | Role | Wiring | +| --- | --- | --- | --- | +| `noema-review` | `ContextualWisdomLab/noema` | GitHub review | this repository | +| `noema-hourly-product-development` | `ContextualWisdomLab/noema` | Product development | this repository | +| `naruon-judgments` | `ContextualWisdomLab/naruon` | Judgments and decisions | separate repository PR | + +naruon is a first-class consumer, not an afterthought. A naruon agent +program that judges or decides must use this contract. It must not hold +provider keys or walk a sequential model list. + +Noema's OIDC token broker, GitHub App identities, and sandbox/runner +boundaries stay in this repository. naruon must keep its own identity and +sandbox boundaries; this contract covers only the LLM gateway. diff --git a/reviewer/README.md b/reviewer/README.md index 8405c72c5..fea8d33c1 100644 --- a/reviewer/README.md +++ b/reviewer/README.md @@ -49,10 +49,10 @@ they hold regardless of what the model says: other failed checks and unresolved non-outdated inline threads remain blocking. 5. **Long reviews stay useful.** The production provider request timeout - defaults to 5,400 seconds, provider 429/5xx responses receive bounded SDK - retries, and a separately authenticated GitHub Models `openai/gpt-4.1` - fallback is used when the primary provider fails. Publication re-reads the - live PR head and refuses stale evidence. + defaults to 5,400 seconds and provider 429/5xx responses receive bounded SDK + retries. Production failover belongs inside `contextual-orchestrator`; Noema + does not sequentially try the next model. Publication re-reads the live PR + head and refuses stale evidence. The GitHub manifest fetch covers all inline review threads (including resolved and outdated state), submitted review bodies, conversation comments, failed @@ -88,16 +88,17 @@ KV-first, with the CI secret environment as bootstrap transport only - `NOEMA_LLM_API_KEY` - `NOEMA_LLM_REQUEST_TIMEOUT_SECONDS` (default `5400`, allowed `60..7200`) - `NOEMA_LLM_MAX_RETRIES` (default `1`, allowed `0..8`) -- `NOEMA_FALLBACK_LLM_MODEL` -- `NOEMA_FALLBACK_LLM_API_URL` -- `NOEMA_FALLBACK_LLM_API_KEY` The trusted central production workflow supplies only the primary `contextual-orchestrator` endpoint and a dedicated gateway inference token. It verifies the gateway's `/healthz` identity and rejects known direct-provider -hosts. Optional `NOEMA_FALLBACK_*` settings remain available to other runtimes, -but production provider failover belongs inside `contextual-orchestrator` so -cost, allowlist, circuit-breaker, and audit policies cannot be bypassed. +hosts. Leftover `NOEMA_FALLBACK_*` settings fail closed. Provider selection +belongs inside `contextual-orchestrator` so cost, allowlist, circuit-breaker, +and audit policies cannot be bypassed by a second model inside Noema. + +The same contract is published for `ContextualWisdomLab/naruon` judgments and +decisions (`contracts/orchestrator-gateway.json`). naruon is a first-class +consumer; its wiring is a separate repository pull request. Publication uses the Noema GitHub-App installation token (from the Worker) or a `NOEMA_REVIEW_TOKEN` fallback with `pull-requests: write`. diff --git a/reviewer/noema_reviewer/config.py b/reviewer/noema_reviewer/config.py index 4c16c743d..d3d6861f6 100644 --- a/reviewer/noema_reviewer/config.py +++ b/reviewer/noema_reviewer/config.py @@ -8,8 +8,9 @@ documented rather than scattered ``os.getenv`` reads. The reviewer talks to an OpenAI-compatible endpoint (the -``contextual-orchestrator`` gateway in production), so a swap of upstream model -is a config change, not a code change. +``contextual-orchestrator`` gateway in production). Upstream model selection +stays in that gateway; leftover sequential ``NOEMA_FALLBACK_*`` settings fail +closed instead of trying the next model inside Noema. """ from __future__ import annotations @@ -35,9 +36,6 @@ class ReviewerConfig: api_key: str request_timeout_seconds: float = 5400.0 max_retries: int = 1 - fallback_model_name: str = "" - fallback_base_url: str = "" - fallback_api_key: str = "" def _read(name: str, credential_getter: CredentialGetter | None) -> str: @@ -69,6 +67,19 @@ def _bounded_int( return value +def _require_single_routing_alias(name: str, value: str) -> None: + """Reject sequential candidate lists and direct-provider model prefixes.""" + if any(character.isspace() for character in value) or "," in value: + raise RuntimeError( + f"{name} must be one routing alias; sequential model candidates are not allowed" + ) + if value.startswith(("nvidia-nim/", "openai/", "github-models/")): + raise RuntimeError( + f"{name} must be the contextual-orchestrator routing alias, " + "not a direct provider model" + ) + + def _require_safe_model_endpoint(name: str, value: str) -> None: """Reject credential-bearing model endpoints that use unsafe remote transport.""" try: @@ -98,9 +109,15 @@ def resolve_config(credential_getter: CredentialGetter | None = None) -> Reviewe "NOEMA_LLM_REQUEST_TIMEOUT_SECONDS", 5400, 60, 7200, credential_getter ) max_retries = _bounded_int("NOEMA_LLM_MAX_RETRIES", 1, 0, 8, credential_getter) - fallback_model_name = _read("NOEMA_FALLBACK_LLM_MODEL", credential_getter) - fallback_base_url = _read("NOEMA_FALLBACK_LLM_API_URL", credential_getter) - fallback_api_key = _read("NOEMA_FALLBACK_LLM_API_KEY", credential_getter) + leftover_fallback = [ + name + for name in ( + "NOEMA_FALLBACK_LLM_MODEL", + "NOEMA_FALLBACK_LLM_API_URL", + "NOEMA_FALLBACK_LLM_API_KEY", + ) + if _read(name, credential_getter) + ] missing = [ name for name, value in ( @@ -116,25 +133,20 @@ def resolve_config(credential_getter: CredentialGetter | None = None) -> Reviewe "Provide them through the credential registry (KV) or the CI secret " "transport before running a review." ) - fallback_values = (fallback_model_name, fallback_base_url, fallback_api_key) - if any(fallback_values) and not all(fallback_values): + if leftover_fallback: raise RuntimeError( - "Noema fallback reviewer configuration is incomplete; provide " - "NOEMA_FALLBACK_LLM_MODEL, NOEMA_FALLBACK_LLM_API_URL, and " - "NOEMA_FALLBACK_LLM_API_KEY together." + "Noema sequential model fallback is not allowed; unset " + + ", ".join(leftover_fallback) + + ". contextual-orchestrator selects min-cost / max-performance." ) + _require_single_routing_alias("NOEMA_LLM_MODEL", model_name) _require_safe_model_endpoint("NOEMA_LLM_API_URL", base_url) - if fallback_model_name: - _require_safe_model_endpoint("NOEMA_FALLBACK_LLM_API_URL", fallback_base_url) return ReviewerConfig( model_name=model_name, base_url=base_url, api_key=api_key, request_timeout_seconds=float(request_timeout_seconds), max_retries=max_retries, - fallback_model_name=fallback_model_name, - fallback_base_url=fallback_base_url, - fallback_api_key=fallback_api_key, ) @@ -145,37 +157,21 @@ def resolve_model(config: ReviewerConfig | None = None) -> Model: (the ``contextual-orchestrator`` gateway in production), so the OpenAI provider is a required dependency rather than an optional extra. """ - from openai import APIConnectionError, AsyncOpenAI - from pydantic_ai.exceptions import ModelAPIError - from pydantic_ai.models.fallback import FallbackModel + from openai import AsyncOpenAI from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider resolved = config or resolve_config() + _require_single_routing_alias("NOEMA_LLM_MODEL", resolved.model_name) _require_safe_model_endpoint("NOEMA_LLM_API_URL", resolved.base_url) - if resolved.fallback_model_name: - _require_safe_model_endpoint("NOEMA_FALLBACK_LLM_API_URL", resolved.fallback_base_url) - - def compatible_model(model_name: str, base_url: str, api_key: str) -> Model: - """Build one OpenAI-compatible model with the shared retry budget.""" - client = AsyncOpenAI( - base_url=base_url, - api_key=api_key, - timeout=resolved.request_timeout_seconds, - max_retries=resolved.max_retries, - ) - return OpenAIChatModel(model_name, provider=OpenAIProvider(openai_client=client)) - - primary = compatible_model(resolved.model_name, resolved.base_url, resolved.api_key) - if not resolved.fallback_model_name: - return primary - fallback = compatible_model( - resolved.fallback_model_name, - resolved.fallback_base_url, - resolved.fallback_api_key, + + client = AsyncOpenAI( + base_url=resolved.base_url, + api_key=resolved.api_key, + timeout=resolved.request_timeout_seconds, + max_retries=resolved.max_retries, ) - return FallbackModel( - primary, - fallback, - fallback_on=(ModelAPIError, APIConnectionError), + return OpenAIChatModel( + resolved.model_name, + provider=OpenAIProvider(openai_client=client), ) diff --git a/reviewer/tests/test_central_review_workflow.py b/reviewer/tests/test_central_review_workflow.py index 8abbc048b..ff339d45f 100644 --- a/reviewer/tests/test_central_review_workflow.py +++ b/reviewer/tests/test_central_review_workflow.py @@ -97,9 +97,18 @@ def test_production_review_requires_contextual_orchestrator_gateway() -> None: assert "NOEMA_LLM_API_KEY: ${{ secrets.OPENAI_API_KEY }}" not in workflow assert "NOEMA_FALLBACK_LLM_API_URL:" not in workflow assert "NOEMA_FALLBACK_LLM_API_KEY:" not in workflow - assert '"service") != "contextual-orchestrator"' in workflow - assert "Noema production review must use contextual-orchestrator" in workflow - assert "Verified contextual-orchestrator gateway identity." in workflow + assert "NOEMA_FALLBACK_LLM_MODEL:" not in workflow + config = ( + Path(__file__).resolve().parents[1] / "noema_reviewer" / "config.py" + ).read_text(encoding="utf-8") + assert "FallbackModel" not in config + assert "sequential model fallback is not allowed" in config + assert "node scripts/verify-orchestrator-gateway.mjs" in workflow + assert "Verified contextual-orchestrator gateway identity." in ( + Path(__file__).resolve().parents[2] + / "scripts" + / "verify-orchestrator-gateway.mjs" + ).read_text(encoding="utf-8") def test_untrusted_codegraph_analysis_uses_an_authenticated_quarantine_image() -> None: diff --git a/reviewer/tests/test_config.py b/reviewer/tests/test_config.py index 4a7d063fd..6f5c99fba 100644 --- a/reviewer/tests/test_config.py +++ b/reviewer/tests/test_config.py @@ -3,9 +3,7 @@ from __future__ import annotations import pytest -from httpx import Request -from openai import APITimeoutError -from pydantic_ai.models.fallback import FallbackModel +from pydantic_ai.models.openai import OpenAIChatModel from noema_reviewer.config import ReviewerConfig, resolve_config, resolve_model @@ -60,42 +58,90 @@ def test_resolve_config_raises_when_unconfigured(monkeypatch) -> None: def test_resolve_model_builds_openai_model() -> None: - """resolve_model builds an OpenAI-compatible model from config.""" + """resolve_model builds one OpenAI-compatible gateway model from config.""" config = ReviewerConfig(model_name="gpt-x", base_url="https://x/v1", api_key="k") model = resolve_model(config) - assert model is not None + assert isinstance(model, OpenAIChatModel) -def test_resolve_config_builds_bounded_fallback(monkeypatch) -> None: - """A complete fallback and long request budget are preserved explicitly.""" +def test_resolve_config_preserves_request_budget_without_sequential_fallback() -> None: + """Timeout and retry knobs stay on the single orchestrator-backed model.""" values = { - "NOEMA_LLM_MODEL": "primary", + "NOEMA_LLM_MODEL": "contextual-orchestrator", "NOEMA_LLM_API_URL": "https://primary.example/v1", "NOEMA_LLM_API_KEY": "primary-key", "NOEMA_LLM_REQUEST_TIMEOUT_SECONDS": "5400", "NOEMA_LLM_MAX_RETRIES": "4", - "NOEMA_FALLBACK_LLM_MODEL": "openai/gpt-4.1", - "NOEMA_FALLBACK_LLM_API_URL": "https://models.github.ai/inference", - "NOEMA_FALLBACK_LLM_API_KEY": "fallback-key", } config = resolve_config(_kv(values)) assert config.request_timeout_seconds == 5400 assert config.max_retries == 4 model = resolve_model(config) - assert isinstance(model, FallbackModel) - timeout = APITimeoutError(Request("POST", "https://primary.example/v1")) - assert model._exception_handlers[0](timeout) is True + assert isinstance(model, OpenAIChatModel) + assert not hasattr(config, "fallback_model_name") -def test_resolve_config_rejects_partial_fallback() -> None: - """A partial fallback fails visibly instead of silently skipping it.""" +def test_resolve_config_rejects_complete_leftover_fallback_bundle() -> None: + """A complete leftover fallback bundle still fails closed.""" values = { - "NOEMA_LLM_MODEL": "primary", + "NOEMA_LLM_MODEL": "contextual-orchestrator", "NOEMA_LLM_API_URL": "https://primary.example/v1", "NOEMA_LLM_API_KEY": "primary-key", "NOEMA_FALLBACK_LLM_MODEL": "openai/gpt-4.1", + "NOEMA_FALLBACK_LLM_API_URL": "https://models.github.ai/inference", + "NOEMA_FALLBACK_LLM_API_KEY": "fallback-key", + } + with pytest.raises(RuntimeError, match="sequential model fallback is not allowed") as excinfo: + resolve_config(_kv(values)) + assert "NOEMA_FALLBACK_LLM_MODEL" in str(excinfo.value) + assert "fallback-key" not in str(excinfo.value) + + +def test_resolve_config_rejects_leftover_fallback_from_env_transport(monkeypatch) -> None: + """Env-transport leftover fallback keys fail closed when no KV getter is used.""" + monkeypatch.setenv("NOEMA_LLM_MODEL", "contextual-orchestrator") + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://primary.example/v1") + monkeypatch.setenv("NOEMA_LLM_API_KEY", "primary-key") + monkeypatch.setenv("NOEMA_FALLBACK_LLM_MODEL", "openai/gpt-4.1") + with pytest.raises(RuntimeError, match="sequential model fallback is not allowed") as excinfo: + resolve_config() + assert "openai/gpt-4.1" not in str(excinfo.value) + + +@pytest.mark.parametrize( + "name", + ( + "NOEMA_FALLBACK_LLM_MODEL", + "NOEMA_FALLBACK_LLM_API_URL", + "NOEMA_FALLBACK_LLM_API_KEY", + ), +) +def test_resolve_config_rejects_leftover_sequential_fallback(name: str) -> None: + """Leftover fallback secrets fail closed instead of enabling a second model.""" + values = { + "NOEMA_LLM_MODEL": "contextual-orchestrator", + "NOEMA_LLM_API_URL": "https://primary.example/v1", + "NOEMA_LLM_API_KEY": "primary-key", + name: "must-not-enable-failover", } - with pytest.raises(RuntimeError, match="fallback reviewer configuration is incomplete"): + with pytest.raises(RuntimeError, match="sequential model fallback is not allowed") as excinfo: + resolve_config(_kv(values)) + assert name in str(excinfo.value) + assert "must-not-enable-failover" not in str(excinfo.value) + + +@pytest.mark.parametrize( + "model_name", + ("alpha beta", "alpha,beta", "nvidia-nim/nvidia/llama", "openai/gpt-4.1", "github-models/openai/gpt-4.1"), +) +def test_resolve_config_rejects_sequential_or_direct_provider_models(model_name: str) -> None: + """The reviewer accepts one routing alias, not a candidate list or provider prefix.""" + values = { + "NOEMA_LLM_MODEL": model_name, + "NOEMA_LLM_API_URL": "https://primary.example/v1", + "NOEMA_LLM_API_KEY": "primary-key", + } + with pytest.raises(RuntimeError, match="NOEMA_LLM_MODEL"): resolve_config(_kv(values)) @@ -115,30 +161,16 @@ def test_resolve_config_rejects_invalid_numeric_bounds(name: str, value: str) -> resolve_config(_kv(values)) -@pytest.mark.parametrize( - ("url_name", "unsafe_url"), - [ - ("NOEMA_LLM_API_URL", "http://reviewer-gateway.example/v1"), - ("NOEMA_FALLBACK_LLM_API_URL", "http://fallback-gateway.example/v1"), - ], -) -def test_resolve_config_rejects_plaintext_remote_model_endpoints( - url_name: str, unsafe_url: str -) -> None: +def test_resolve_config_rejects_plaintext_remote_model_endpoints() -> None: """Credential-bearing remote model endpoints must not use plaintext HTTP.""" values = { "NOEMA_LLM_MODEL": "primary", - "NOEMA_LLM_API_URL": "https://primary.example/v1", + "NOEMA_LLM_API_URL": "http://reviewer-gateway.example/v1", "NOEMA_LLM_API_KEY": "primary-key", - "NOEMA_FALLBACK_LLM_MODEL": "fallback", - "NOEMA_FALLBACK_LLM_API_URL": "https://fallback.example/v1", - "NOEMA_FALLBACK_LLM_API_KEY": "fallback-key", - url_name: unsafe_url, } - with pytest.raises(RuntimeError, match=url_name) as excinfo: + with pytest.raises(RuntimeError, match="NOEMA_LLM_API_URL") as excinfo: resolve_config(_kv(values)) assert "primary-key" not in str(excinfo.value) - assert "fallback-key" not in str(excinfo.value) def test_resolve_config_rejects_malformed_model_endpoint_with_bounded_error() -> None: @@ -162,21 +194,27 @@ def test_resolve_config_rejects_malformed_model_endpoint_with_bounded_error() -> api_key="primary-key", ), ReviewerConfig( - model_name="primary", + model_name="openai/gpt-4.1", base_url="https://primary.example/v1", api_key="primary-key", - fallback_model_name="fallback", - fallback_base_url="http://fallback-gateway.example/v1", - fallback_api_key="fallback-key", ), ], ) def test_resolve_model_rejects_manually_constructed_unsafe_config(config: ReviewerConfig) -> None: - """Injected ReviewerConfig cannot bypass endpoint transport validation.""" + """Injected ReviewerConfig cannot bypass endpoint or routing-alias validation.""" with pytest.raises(RuntimeError): resolve_model(config) +def test_resolve_model_reads_live_config_when_none_is_passed(monkeypatch) -> None: + """Omitting config still resolves the single gateway model from transport.""" + monkeypatch.setenv("NOEMA_LLM_MODEL", "contextual-orchestrator") + monkeypatch.setenv("NOEMA_LLM_API_URL", "https://orchestrator.example/v1") + monkeypatch.setenv("NOEMA_LLM_API_KEY", "gateway-token") + model = resolve_model() + assert isinstance(model, OpenAIChatModel) + + @pytest.mark.parametrize("host", ["localhost", "127.0.0.1", "[::1]"]) def test_resolve_config_allows_loopback_http_model_endpoint(host: str) -> None: """Local development may use plaintext HTTP only on an exact loopback host.""" diff --git a/scripts/acquisition-data-room-integrity-audit.mjs b/scripts/acquisition-data-room-integrity-audit.mjs index 74740d56f..b30ed6e4a 100644 --- a/scripts/acquisition-data-room-integrity-audit.mjs +++ b/scripts/acquisition-data-room-integrity-audit.mjs @@ -82,10 +82,12 @@ try { // helper are the CI bootstrap trust root; retained data-room artifacts remain // untrusted. const { verifyDataRoomManifestFile } = await import("./lib/acquisition-data-room-integrity.mjs"); + const { DATA_ROOM_CATALOG } = await import("./lib/acquisition-data-room-catalog.mjs"); const result = verifyDataRoomManifestFile(manifestPath, { rootDir: process.cwd(), expectedCommitSha, ...release, + catalog: DATA_ROOM_CATALOG, }); const output = { schemaVersion: 1, diff --git a/scripts/acquisition-data-room-manifest-secure.mjs b/scripts/acquisition-data-room-manifest-secure.mjs index b1f48a142..02d69bf3f 100644 --- a/scripts/acquisition-data-room-manifest-secure.mjs +++ b/scripts/acquisition-data-room-manifest-secure.mjs @@ -56,17 +56,19 @@ try { || join(outputDir, "data-room-manifest.json"); const release = resolveRelease(); - // The verifier/catalog module is intentionally loaded only after the tracked + // The verifier/catalog modules are intentionally loaded only after the tracked // checkout has been authenticated against exact HEAD. The small preflight // module, private-output helper, and this entrypoint are the bootstrap trust // root executed by CI. const { materializeDataRoomManifest } = await import("./lib/acquisition-data-room-integrity.mjs"); + const { DATA_ROOM_CATALOG } = await import("./lib/acquisition-data-room-catalog.mjs"); const output = materializeDataRoomManifest({ rootDir: process.cwd(), manifestPath, commitSha, ...release, generatedAt: now, + catalog: DATA_ROOM_CATALOG, }); // Refuse source movement or tracked mutation that occurred while evidence was diff --git a/scripts/lib/acquisition-data-room-catalog.mjs b/scripts/lib/acquisition-data-room-catalog.mjs new file mode 100644 index 000000000..c1eeb3caf --- /dev/null +++ b/scripts/lib/acquisition-data-room-catalog.mjs @@ -0,0 +1,33 @@ +import { DATA_ROOM_CATALOG as BASE_DATA_ROOM_CATALOG } from "./acquisition-data-room-integrity.mjs"; + +/** Build one immutable required file entry for the composed buyer catalog. */ +function requiredFile(id, category, path) { + return Object.freeze({ + id, + category, + kind: "file", + path, + required: true, + requiredForFinalGate: true, + }); +} + +/** + * Noema-specific product evidence layered on top of the hardened acquisition + * integrity catalog. The integrity verifier receives this exact composed + * catalog from both production entrypoints, so gateway evidence remains inside + * the same strict immutable-entry validation boundary as the base catalog. + */ +export const DATA_ROOM_CATALOG = Object.freeze([ + ...BASE_DATA_ROOM_CATALOG, + requiredFile( + "orchestrator-gateway-contract", + "product", + "contracts/orchestrator-gateway.json", + ), + requiredFile( + "orchestrator-gateway-consumer-doc", + "product", + "docs/orchestrator-gateway-consumer-contract.md", + ), +]); diff --git a/scripts/lib/orchestrator-gateway.mjs b/scripts/lib/orchestrator-gateway.mjs new file mode 100644 index 000000000..7eb137971 --- /dev/null +++ b/scripts/lib/orchestrator-gateway.mjs @@ -0,0 +1,513 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { hasDuplicateJsonObjectKeys } from "../normalize-commercial-readiness-evidence.mjs"; + +const DEFAULT_ROUTING_ALIAS = "contextual-orchestrator"; +const HEALTH_TIMEOUT_MS = 15_000; +const HEALTH_BODY_LIMIT_BYTES = 65_536; +const fatalHealthUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); +const DIRECT_PROVIDER_HOSTS = Object.freeze([ + "api.openai.com", + "models.github.ai", + "openrouter.ai", + "integrate.api.nvidia.com", + "api.nvidia.com", + "api.bytez.com", +]); +const OPENCODE_PROVIDER_ID = "contextual-orchestrator"; +const NON_SECRET_TRANSPORT_NAMES = new Set([ + "NOEMA_LLM_API_URL", + "NOEMA_LLM_MODEL", +]); +const FORBIDDEN_PROVIDER_KEYS = Object.freeze([ + "NVIDIA_NIM_API_KEY", + "NVIDIA_NIM_API_KEY_SUB", + "BYTEZ_API_KEY", + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "COPILOT_GITHUB_TOKEN", +]); +const GATEWAY_CONSUMERS = Object.freeze([ + Object.freeze({ + id: "noema-review", + repository: "ContextualWisdomLab/noema", + role: "github-review", + wiring: "this-repository", + }), + Object.freeze({ + id: "noema-hourly-product-development", + repository: "ContextualWisdomLab/noema", + role: "product-development", + wiring: "this-repository", + }), + Object.freeze({ + id: "naruon-judgments", + repository: "ContextualWisdomLab/naruon", + role: "judgments-and-decisions", + wiring: "separate-repository-pr", + }), +]); + +/** + * Hostnames that implement an OpenAI-compatible API but are direct providers. + * Noema must not call these; provider selection stays in the orchestrator. + * + * @returns {readonly string[]} Exact lowercase hostnames. + */ +export function directProviderHosts() { + return DIRECT_PROVIDER_HOSTS; +} + +/** + * Default routing alias the orchestrator uses to pick min-cost / max-performance. + * + * @returns {string} Gateway model name. + */ +export function defaultOrchestratorModel() { + return DEFAULT_ROUTING_ALIAS; +} + +/** + * Upstream provider and Copilot token names that must stay out of Noema and naruon. + * + * @returns {readonly string[]} Exact environment/secret names. + */ +export function forbiddenProviderKeys() { + return FORBIDDEN_PROVIDER_KEYS; +} + +/** + * First-class LLM consumers of this gateway contract. + * + * naruon judgments and decisions are a first-class consumer. Wiring that + * runtime is a separate repository pull request. + * + * @returns {readonly object[]} Consumer descriptors without secrets. + */ +export function orchestratorGatewayConsumers() { + return GATEWAY_CONSUMERS; +} + +/** + * Secret-free consumer contract that naruon can copy or import. + * + * This is the reusable Noema-side interface: HTTPS `/v1` URL, routing alias + * `contextual-orchestrator`, dedicated inference token, no provider keys, and + * no sequential model list. It does not include the OpenCode config writer. + * + * @returns {Readonly} Machine-readable contract. + */ +export function orchestratorGatewayConsumerContract() { + return Object.freeze({ + id: "contextual-orchestrator-gateway", + version: 1, + service: "contextual-orchestrator", + routing_alias: DEFAULT_ROUTING_ALIAS, + api_url: Object.freeze({ + scheme: "https", + pathname_suffix: "/v1", + allow_userinfo: false, + allow_query: false, + allow_fragment: false, + }), + healthz: Object.freeze({ + unauthenticated: true, + identity: Object.freeze({ + status: "ok", + service: "contextual-orchestrator", + }), + }), + transport_names: Object.freeze({ + api_url: "NOEMA_LLM_API_URL", + model: "NOEMA_LLM_MODEL", + api_key: "NOEMA_LLM_API_KEY", + }), + dedicated_inference_token: true, + sequential_model_candidates: false, + forbidden_provider_keys: FORBIDDEN_PROVIDER_KEYS, + forbidden_direct_provider_hosts: DIRECT_PROVIDER_HOSTS, + consumers: GATEWAY_CONSUMERS, + naruon_first_class_consumer: true, + naruon_wiring: "separate-repository-pr", + }); +} + +/** + * Serialize the consumer contract as stable pretty-printed JSON. + * + * @returns {string} JSON document with a trailing newline. + */ +export function serializeOrchestratorGatewayConsumerContract() { + return `${JSON.stringify(orchestratorGatewayConsumerContract(), null, 2)}\n`; +} + +/** + * Read one non-secret CI transport setting. + * + * The gateway preflight is intentionally limited to URL and routing-alias + * configuration. Secret names are rejected before the transport map is read so + * a caller cannot use this helper to source credentials from `process.env`. + * + * @param {NodeJS.ProcessEnv | Record} source Transport map. + * @param {string} name Setting name. + * @returns {string} Trimmed value, or an empty string when absent. + * @throws {Error} When `name` is not an approved non-secret gateway setting. + */ +export function readGatewayTransportValue(source, name) { + if (!NON_SECRET_TRANSPORT_NAMES.has(name)) { + throw new Error("gateway preflight may read only non-secret gateway settings"); + } + const raw = source?.[name]; + return typeof raw === "string" ? raw.trim() : ""; +} + +/** + * Redact credential-shaped tokens from a diagnostic string. + * + * @param {unknown} error Failure to render. + * @returns {string} Bounded, non-secret diagnostic. + */ +export function boundedGatewayError(error) { + return String(error?.message ?? error) + .replace(/\b(?:sk-|nvapi-|bytez_|or-)?[A-Za-z0-9_\-]{16,}\b/g, "[REDACTED]") + .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]") + .replace(/[\u0000-\u001f\u007f]/g, "") + .slice(0, 1_024); +} + +/** + * Parse and accept only an HTTPS OpenAI-compatible gateway base URL ending in /v1. + * + * @param {string} rawUrl Candidate `NOEMA_LLM_API_URL`. + * @returns {{ href: string, healthzUrl: string, hostname: string }} Canonical URL parts. + * @throws {Error} When the URL is not the production gateway contract. + */ +export function parseOrchestratorGatewayUrl(rawUrl) { + const apiUrl = String(rawUrl ?? "").trim(); + let parsed; + try { + parsed = new URL(apiUrl); + } catch { + throw new Error("NOEMA_LLM_API_URL must be an absolute HTTPS URL"); + } + if (parsed.protocol !== "https:") { + throw new Error("NOEMA_LLM_API_URL must be an absolute HTTPS URL"); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + throw new Error( + "NOEMA_LLM_API_URL must not contain credentials, query, or fragment", + ); + } + const hostname = parsed.hostname.toLowerCase().replace(/\.+$/u, ""); + if (!hostname) { + throw new Error("NOEMA_LLM_API_URL must be an absolute HTTPS URL"); + } + if (DIRECT_PROVIDER_HOSTS.includes(hostname)) { + throw new Error( + "Noema production jobs must use contextual-orchestrator, not a direct model provider", + ); + } + const path = parsed.pathname.replace(/\/+$/u, "") || ""; + if (!path.endsWith("/v1")) { + throw new Error("NOEMA_LLM_API_URL must end in /v1"); + } + const healthPath = `${path.slice(0, -3)}/healthz`; + parsed.pathname = path; + parsed.search = ""; + parsed.hash = ""; + const healthUrl = new URL(parsed.href); + healthUrl.pathname = healthPath; + return { + href: parsed.href, + healthzUrl: healthUrl.href, + hostname, + }; +} + +/** + * Resolve the one production routing alias. Empty input becomes the canonical alias. + * + * @param {string} rawModel Candidate `NOEMA_LLM_MODEL`. + * @returns {string} Canonical contextual-orchestrator routing alias. + * @throws {Error} When the value is a candidate list, a direct-provider model, or another alias. + */ +export function resolveOrchestratorModel(rawModel) { + const model = String(rawModel ?? "").trim() || DEFAULT_ROUTING_ALIAS; + if (/\s/u.test(model) || model.includes(",")) { + throw new Error( + "NOEMA_LLM_MODEL must be one routing alias; sequential model candidates are not allowed", + ); + } + if (model.startsWith("nvidia-nim/") || model.startsWith("openai/") || model.startsWith("github-models/")) { + throw new Error( + "NOEMA_LLM_MODEL must be the contextual-orchestrator routing alias, not a direct provider model", + ); + } + if (model !== DEFAULT_ROUTING_ALIAS) { + throw new Error( + `NOEMA_LLM_MODEL must equal ${DEFAULT_ROUTING_ALIAS} so model/provider selection remains inside contextual-orchestrator`, + ); + } + return model; +} + +/** + * Require a dedicated gateway inference token without returning or logging it. + * + * @param {string} rawKey Candidate `NOEMA_LLM_API_KEY`. + * @returns {void} + * @throws {Error} When the dedicated gateway token is missing. + */ +export function requireOrchestratorApiKey(rawKey) { + if (!String(rawKey ?? "").trim()) { + throw new Error("NOEMA_LLM_API_KEY is not configured"); + } +} + +/** + * Fetch `/healthz` without a bearer token and require the orchestrator identity. + * + * The response body is consumed incrementally under the same wall-clock timeout + * as the request. Both an advertised oversized body and a chunked body that + * crosses the byte ceiling are rejected before unbounded materialization. The + * bounded body must also be valid UTF-8 JSON with no duplicate decoded keys so + * last-key-wins parser ambiguity cannot manufacture the expected identity. + * + * @param {string} healthzUrl Absolute health URL derived from the `/v1` base. + * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options] + * @returns {Promise<{ status: string, service: string }>} Parsed health document. + * @throws {Error} When the response is not a bounded orchestrator identity. + */ +export async function verifyOrchestratorHealthz(healthzUrl, options = {}) { + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + const timeoutMs = options.timeoutMs ?? HEALTH_TIMEOUT_MS; + if (typeof fetchImpl !== "function") { + throw new Error("orchestrator healthz verification requires fetch"); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + if (timeoutMs <= 0) { + controller.abort(); + } + const timeoutPromise = new Promise((_, reject) => { + const onAbort = () => { + reject(new Error("contextual-orchestrator health request timed out")); + }; + if (controller.signal.aborted) { + onAbort(); + return; + } + controller.signal.addEventListener("abort", onAbort, { once: true }); + }); + + let response; + try { + response = await Promise.race([ + fetchImpl(healthzUrl, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": "noema-orchestrator-gateway", + }, + redirect: "error", + signal: controller.signal, + }), + timeoutPromise, + ]); + } catch (error) { + clearTimeout(timer); + throw new Error( + `contextual-orchestrator health request failed: ${boundedGatewayError(error)}`, + ); + } + + try { + if (!response.ok) { + throw new Error( + `contextual-orchestrator health response status is ${response.status}`, + ); + } + const advertisedLength = response.headers?.get?.("content-length"); + if ( + typeof advertisedLength === "string" && + /^\d+$/u.test(advertisedLength.trim()) && + Number(advertisedLength) > HEALTH_BODY_LIMIT_BYTES + ) { + await response.body?.cancel?.().catch(() => undefined); + throw new Error("contextual-orchestrator health response is too large"); + } + + let raw; + const reader = response.body?.getReader?.(); + if (reader) { + const chunks = []; + let totalBytes = 0; + let completed = false; + try { + while (true) { + const result = await Promise.race([reader.read(), timeoutPromise]); + if (result.done) { + completed = true; + break; + } + const chunk = Buffer.from(result.value ?? new Uint8Array()); + totalBytes += chunk.length; + if (totalBytes > HEALTH_BODY_LIMIT_BYTES) { + throw new Error("contextual-orchestrator health response is too large"); + } + chunks.push(chunk); + } + } finally { + if (!completed) { + try { + await reader.cancel(); + } catch { + // Cancellation is cleanup only; the primary bounded-read failure wins. + } + } + reader.releaseLock(); + } + raw = Buffer.concat(chunks, totalBytes); + } else { + raw = Buffer.from(await Promise.race([response.arrayBuffer(), timeoutPromise])); + if (raw.length > HEALTH_BODY_LIMIT_BYTES) { + throw new Error("contextual-orchestrator health response is too large"); + } + } + + let text; + try { + text = fatalHealthUtf8Decoder.decode(raw); + } catch { + throw new Error("contextual-orchestrator health response is not valid UTF-8"); + } + + let health; + try { + if (hasDuplicateJsonObjectKeys(text)) { + throw new TypeError("contextual-orchestrator health response has duplicate decoded JSON keys"); + } + health = JSON.parse(text); + } catch (error) { + if (error instanceof TypeError && error.message.includes("duplicate decoded JSON keys")) { + throw error; + } + throw new Error("contextual-orchestrator health response is not JSON"); + } + if (health?.status !== "ok" || health?.service !== "contextual-orchestrator") { + throw new Error("NOEMA_LLM_API_URL did not identify contextual-orchestrator"); + } + return { status: health.status, service: health.service }; + } catch (error) { + if (controller.signal.aborted) { + throw new Error( + `contextual-orchestrator health request failed: ${boundedGatewayError(error)}`, + ); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +/** + * Build the single-provider OpenCode config that targets the gateway only. + * + * @param {{ apiUrl: string, model: string }} settings Validated gateway settings. + * @returns {object} OpenCode configuration object. + */ +export function buildOpenCodeOrchestratorConfig(settings) { + const gateway = parseOrchestratorGatewayUrl(settings.apiUrl); + const model = resolveOrchestratorModel(settings.model); + const providerModel = `${OPENCODE_PROVIDER_ID}/${model}`; + return { + $schema: "https://opencode.ai/config.json", + share: "disabled", + autoupdate: false, + lsp: false, + mcp: {}, + enabled_providers: [OPENCODE_PROVIDER_ID], + model: providerModel, + small_model: providerModel, + permission: { + "*": "allow", + external_directory: "deny", + task: "deny", + question: "deny", + webfetch: "deny", + websearch: "deny", + bash: "deny", + }, + provider: { + [OPENCODE_PROVIDER_ID]: { + npm: "@ai-sdk/openai-compatible", + name: "Contextual Orchestrator", + options: { + baseURL: gateway.href, + apiKey: "{env:NOEMA_LLM_API_KEY}", + }, + models: { + [model]: { + name: "Contextual Orchestrator", + tool_call: true, + limit: { context: 131072, output: 8192 }, + }, + }, + }, + }, + }; +} + +/** + * Write the owner-only OpenCode config after the gateway URL is validated. + * + * @param {string} outputPath Destination file. + * @param {{ apiUrl: string, model: string }} settings Validated gateway settings. + * @returns {object} Written configuration. + */ +export function writeOpenCodeOrchestratorConfig(outputPath, settings) { + const config = buildOpenCodeOrchestratorConfig(settings); + mkdirSync(dirname(outputPath), { recursive: true, mode: 0o700 }); + writeFileSync(outputPath, `${JSON.stringify(config, null, 2)}\n`, { + encoding: "utf8", + mode: 0o400, + }); + return config; +} + +/** + * Validate non-secret transport settings, confirm `/healthz`, and optionally write OpenCode config. + * + * The verifier deliberately does not read `NOEMA_LLM_API_KEY`; credential + * presence is enforced by the credential-consuming workflow step, while this + * boundary validates only unauthenticated gateway identity and routing config. + * + * @param {object} input + * @param {NodeJS.ProcessEnv | Record} input.env Transport map. + * @param {typeof fetch} [input.fetchImpl] + * @param {string} [input.openCodeConfigPath] + * @returns {Promise<{ apiUrl: string, model: string, healthzUrl: string }>} + */ +export async function verifyOrchestratorGatewayContract(input) { + const env = input.env ?? {}; + const apiUrl = readGatewayTransportValue(env, "NOEMA_LLM_API_URL"); + const model = resolveOrchestratorModel( + readGatewayTransportValue(env, "NOEMA_LLM_MODEL"), + ); + const gateway = parseOrchestratorGatewayUrl(apiUrl); + await verifyOrchestratorHealthz(gateway.healthzUrl, { + fetchImpl: input.fetchImpl, + }); + if (input.openCodeConfigPath) { + writeOpenCodeOrchestratorConfig(input.openCodeConfigPath, { + apiUrl: gateway.href, + model, + }); + } + return { + apiUrl: gateway.href, + model, + healthzUrl: gateway.healthzUrl, + }; +} diff --git a/scripts/verify-orchestrator-gateway.mjs b/scripts/verify-orchestrator-gateway.mjs new file mode 100644 index 000000000..c172b3bc6 --- /dev/null +++ b/scripts/verify-orchestrator-gateway.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + defaultOrchestratorModel, + parseOrchestratorGatewayUrl, + resolveOrchestratorModel, + serializeOrchestratorGatewayConsumerContract, + verifyOrchestratorHealthz, + writeOpenCodeOrchestratorConfig, +} from "./lib/orchestrator-gateway.mjs"; + +/** + * Parse `--print-contract` and the optional `--write-opencode-config PATH` flag. + * + * @param {string[]} argv Process arguments after the script name. + * @returns {{ openCodeConfigPath: string, printContract: boolean }} Parsed CLI options. + * @throws {Error} When the flag is present without a path or is unknown. + */ +export function parseVerifyOrchestratorGatewayArgs(argv) { + const args = [...argv]; + let openCodeConfigPath = ""; + let printContract = false; + while (args.length > 0) { + const flag = args.shift(); + if (flag === "--print-contract") { + printContract = true; + continue; + } + if (flag === "--write-opencode-config") { + const path = args.shift(); + if (!path) { + throw new Error("--write-opencode-config requires a destination path"); + } + openCodeConfigPath = path; + continue; + } + throw new Error(`Unknown argument: ${flag}`); + } + return { openCodeConfigPath, printContract }; +} + +/** + * Run the secret-free gateway identity preflight. + * + * The preflight validates only non-secret transport configuration and the + * unauthenticated `/healthz` identity. It deliberately never reads + * `NOEMA_LLM_API_KEY`; the downstream OpenCode or reviewer process is the only + * consumer of that dedicated inference credential. + * + * @param {object} input + * @param {string[]} input.argv + * @param {NodeJS.ProcessEnv} input.env + * @param {typeof fetch} [input.fetchImpl] + * @param {(message: string) => void} input.writeStdout + * @param {(message: string) => void} input.writeStderr + * @returns {Promise} Process exit code. + */ +export async function runVerifyOrchestratorGatewayCli(input) { + try { + const options = parseVerifyOrchestratorGatewayArgs(input.argv); + if (options.printContract) { + input.writeStdout(serializeOrchestratorGatewayConsumerContract()); + return 0; + } + + const configuredModel = String(input.env?.NOEMA_LLM_MODEL ?? "").trim(); + const routingAlias = defaultOrchestratorModel(); + if (configuredModel && configuredModel !== routingAlias) { + throw new Error( + `NOEMA_LLM_MODEL must equal ${routingAlias} so model/provider selection remains inside contextual-orchestrator`, + ); + } + + const model = resolveOrchestratorModel(configuredModel); + const gateway = parseOrchestratorGatewayUrl( + String(input.env?.NOEMA_LLM_API_URL ?? "").trim(), + ); + await verifyOrchestratorHealthz(gateway.healthzUrl, { + fetchImpl: input.fetchImpl, + }); + if (options.openCodeConfigPath) { + writeOpenCodeOrchestratorConfig(options.openCodeConfigPath, { + apiUrl: gateway.href, + model, + }); + } + + input.writeStdout("Verified contextual-orchestrator gateway identity.\n"); + input.writeStdout( + `Noema provider contract: gateway=contextual-orchestrator primary=${model}.\n`, + ); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + input.writeStderr(`::error::Noema contextual-orchestrator preflight failed: ${message}\n`); + return 1; + } +} + +/** + * Write one CLI success line to stdout. + * + * @param {string} message Diagnostic text. + * @returns {void} + */ +export function writeVerifyOrchestratorGatewayStdout(message) { + process.stdout.write(message); +} + +/** + * Write one CLI failure line to stderr. + * + * @param {string} message Diagnostic text. + * @returns {void} + */ +export function writeVerifyOrchestratorGatewayStderr(message) { + process.stderr.write(message); +} + +/** + * Resolve the file URL of the process entrypoint, if any. + * + * @param {string | undefined} argv1 `process.argv[1]`. + * @returns {string} File URL, or an empty string when argv[1] is absent. + */ +export function resolveVerifyOrchestratorGatewayInvokedHref(argv1) { + return argv1 ? pathToFileURL(resolve(argv1)).href : ""; +} + +/** + * Bind only non-secret process configuration into the injectable CLI runner. + * + * The process may carry `NOEMA_LLM_API_KEY` for a later credential-consuming + * program in the same workflow step. This adapter intentionally copies only + * the URL and routing alias, so the preflight cannot observe or forward the + * inference secret. Optional writers let tests consume expected failure output + * without emitting GitHub workflow commands from negative-path assertions. + * + * @param {{ argv?: string[], env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, writeStdout?: (message: string) => void, writeStderr?: (message: string) => void }} [processLike] + * @returns {() => Promise} CLI operation used by the module entrypoint. + */ +export function createVerifyOrchestratorGatewayProcessCli(processLike = process) { + const processEnv = processLike.env ?? {}; + const preflightEnv = { + NOEMA_LLM_API_URL: processEnv.NOEMA_LLM_API_URL, + NOEMA_LLM_MODEL: processEnv.NOEMA_LLM_MODEL, + }; + return () => runVerifyOrchestratorGatewayCli({ + argv: (processLike.argv ?? []).slice(2), + env: preflightEnv, + fetchImpl: processLike.fetchImpl, + writeStdout: processLike.writeStdout ?? writeVerifyOrchestratorGatewayStdout, + writeStderr: processLike.writeStderr ?? writeVerifyOrchestratorGatewayStderr, + }); +} + +/** + * Execute the CLI only for a direct module invocation. + * + * @param {boolean} invoked Whether this file is the Node entrypoint. + * @param {() => Promise} cli Trusted CLI operation. + * @returns {Promise} + */ +export async function runVerifyOrchestratorGatewayEntrypoint(invoked, cli) { + if (!invoked) return; + process.exitCode = await cli(); +} + +const invokedPath = resolveVerifyOrchestratorGatewayInvokedHref(process.argv[1]); + +await runVerifyOrchestratorGatewayEntrypoint( + invokedPath === import.meta.url, + createVerifyOrchestratorGatewayProcessCli(), +); diff --git a/test/acquisition-data-room-catalog.test.ts b/test/acquisition-data-room-catalog.test.ts new file mode 100644 index 000000000..67cc43889 --- /dev/null +++ b/test/acquisition-data-room-catalog.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { DATA_ROOM_CATALOG as BASE_DATA_ROOM_CATALOG } from "../scripts/lib/acquisition-data-room-integrity.mjs"; +import { DATA_ROOM_CATALOG } from "../scripts/lib/acquisition-data-room-catalog.mjs"; + +describe("composed acquisition data-room catalog", () => { + it("preserves the hardened base catalog and appends immutable required gateway evidence", () => { + expect(Object.isFrozen(DATA_ROOM_CATALOG)).toBe(true); + expect(DATA_ROOM_CATALOG.slice(0, BASE_DATA_ROOM_CATALOG.length)).toEqual( + BASE_DATA_ROOM_CATALOG, + ); + + const appendedEntries = DATA_ROOM_CATALOG.slice(BASE_DATA_ROOM_CATALOG.length); + expect(appendedEntries).toEqual([ + { + id: "orchestrator-gateway-contract", + category: "product", + kind: "file", + path: "contracts/orchestrator-gateway.json", + required: true, + requiredForFinalGate: true, + }, + { + id: "orchestrator-gateway-consumer-doc", + category: "product", + kind: "file", + path: "docs/orchestrator-gateway-consumer-contract.md", + required: true, + requiredForFinalGate: true, + }, + ]); + expect(appendedEntries.every((entry) => Object.isFrozen(entry))).toBe(true); + }); +}); diff --git a/test/ci-exact-head-contract.test.ts b/test/ci-exact-head-contract.test.ts index 85c1bed60..0eb8a25f6 100644 --- a/test/ci-exact-head-contract.test.ts +++ b/test/ci-exact-head-contract.test.ts @@ -52,10 +52,30 @@ describe("pull-request verification exact-head checkout contract", () => { expectPinnedApplicationToolchain(workflow); }); + it("binds lockfile verification to one fresh live base instead of the historical PR base snapshot", () => { + const workflow = readWorkflow(workflowPaths[0]); + + expect(workflow).toContain( + 'git merge-base --is-ancestor "$live_base_sha" "$NOEMA_EXPECTED_HEAD_SHA"', + ); + expect(workflow).toContain( + 'printf \'NOEMA_LIVE_BASE_SHA=%s\\n\' "$live_base_sha" >> "$GITHUB_ENV"', + ); + expect(workflow).toContain( + 'git show "${NOEMA_LIVE_BASE_SHA}:package-lock.json" >"$base_lock"', + ); + expect(workflow).toContain('NOEMA_LOCKFILE_BASE_SHA="$NOEMA_LIVE_BASE_SHA"'); + expect(workflow).toContain('if [ "$live_base_sha" != "$NOEMA_LIVE_BASE_SHA" ]; then'); + expect(workflow).not.toContain( + 'NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}', + ); + expect(workflow).not.toContain('test "$live_base_sha" = "$NOEMA_PR_BASE_SHA"'); + }); + it("binds reviewer CI to the immutable pull-request head before reviewer dependency installation", () => { expectExactHeadContract( readWorkflow(workflowPaths[1]), "- name: install (hash-pinned dependencies)", ); }); -}); +}); \ No newline at end of file diff --git a/test/helpers/hourly-workflow.ts b/test/helpers/hourly-workflow.ts index 698aae18d..6c47a7a24 100644 --- a/test/helpers/hourly-workflow.ts +++ b/test/helpers/hourly-workflow.ts @@ -1,30 +1,16 @@ -const candidateModelPattern = /^ nvidia-nim\/.+$/gm; -const fallbackStepName = "- name: Run bounded NVIDIA NIM model fallback"; - /** Seconds reserved for setup work and the stable terminal diagnostic. */ export const SETUP_AND_DIAGNOSTIC_RESERVE_SECONDS = 300; -/** Parsed candidate and cleanup budgets from the production workflow. */ -export interface CandidateBudget { - candidateCount: number; - candidateSeconds: number; - candidateGraceSeconds: number; - reinstallSeconds: number; - reinstallGraceSeconds: number; - interCandidateCleanupCount: number; +const singleRunStepName = "- name: Run one contextual-orchestrator OpenCode session"; + +/** Parsed single-run and proposer-job budgets from the production workflow. */ +export interface SingleRunBudget { + runSeconds: number; + killGraceSeconds: number; jobSeconds: number; totalSeconds: number; } -/** Ordered offsets for the production final-candidate cleanup control flow. */ -export interface CandidateControlFlow { - candidateListIndex: number; - candidateLoopIndex: number; - finalCandidateGuardIndex: number; - resetIndex: number; - reinstallIndex: number; -} - /** * Return one complete job block from the workflow text. * @@ -83,118 +69,63 @@ function readPositiveCapture( } /** - * Read the configured candidate, cleanup, and proposer-job budgets. + * Read the configured single-run and proposer-job budgets. + * + * Sequential model-candidate failover is forbidden, so the budget is one + * gateway-backed OpenCode session plus setup/diagnostic reserve. * * @param workflow Complete workflow YAML. * @returns Parsed budget values and their enforced worst-case total. */ -export function readCandidateBudget(workflow: string): CandidateBudget { +export function readSingleRunBudget(workflow: string): SingleRunBudget { const proposer = readJobSlice( workflow, "propose_product_increment", "package_product_increment", ); - const candidateSeconds = readPositiveCapture( + const runSeconds = readPositiveCapture( workflow, /OPENCODE_RUN_TIMEOUT_SECONDS: "(\d+)"/, - "candidate timeout", + "OpenCode run timeout", ); - const candidateGraceSeconds = readPositiveCapture( + const killGraceSeconds = readPositiveCapture( workflow, /OPENCODE_KILL_GRACE_SECONDS: "(\d+)"/, - "candidate kill grace", - ); - const reinstallSeconds = readPositiveCapture( - workflow, - /DEPENDENCY_REINSTALL_TIMEOUT_SECONDS: "(\d+)"/, - "dependency reinstall timeout", - ); - const reinstallGraceSeconds = readPositiveCapture( - workflow, - /DEPENDENCY_REINSTALL_KILL_GRACE_SECONDS: "(\d+)"/, - "dependency reinstall kill grace", + "OpenCode kill grace", ); const jobMinutes = readPositiveCapture( proposer, /timeout-minutes: (\d+)/, "proposal-job timeout", ); - const candidateCount = workflow.match(candidateModelPattern)?.length ?? 0; - const interCandidateCleanupCount = Math.max(candidateCount - 1, 0); const jobSeconds = jobMinutes * 60; - const totalSeconds = candidateCount * ( - candidateSeconds + candidateGraceSeconds - ) + interCandidateCleanupCount * ( - reinstallSeconds + reinstallGraceSeconds - ) + SETUP_AND_DIAGNOSTIC_RESERVE_SECONDS; + const totalSeconds = runSeconds + killGraceSeconds + + SETUP_AND_DIAGNOSTIC_RESERVE_SECONDS; return { - candidateCount, - candidateSeconds, - candidateGraceSeconds, - reinstallSeconds, - reinstallGraceSeconds, - interCandidateCleanupCount, + runSeconds, + killGraceSeconds, jobSeconds, totalSeconds, }; } /** - * Validate and return the ordered final-candidate cleanup control flow. + * Return the single OpenCode session step, failing if sequential fallback remains. * * @param workflow Complete workflow YAML. - * @returns Ordered offsets within the fallback step. - * @throws {Error} When a required anchor is absent or cleanup can precede the - * final-candidate guard. + * @returns The single-run step text. + * @throws {Error} When the single-run step is missing. */ -export function readCandidateControlFlow( - workflow: string, -): CandidateControlFlow { +export function readSingleOrchestratorRunStep(workflow: string): string { const proposer = readJobSlice( workflow, "propose_product_increment", "package_product_increment", ); - const fallbackStart = proposer.indexOf(fallbackStepName); - if (fallbackStart < 0) { - throw new Error("Workflow bounded fallback step is missing."); - } - const fallback = proposer.slice(fallbackStart); - const anchors = { - candidateListIndex: fallback.indexOf( - 'read -r -a model_candidates <<<"$OPENCODE_MODEL_CANDIDATES"', - ), - candidateLoopIndex: fallback.indexOf( - "for ((candidate_index = 0; candidate_index < candidate_count; candidate_index++)); do", - ), - finalCandidateGuardIndex: fallback.indexOf( - 'if [ "$candidate_index" -eq $((candidate_count - 1)) ]; then', - ), - resetIndex: fallback.indexOf( - 'git -C "$GITHUB_WORKSPACE" reset --hard HEAD', - ), - reinstallIndex: fallback.indexOf( - 'timeout --kill-after="${DEPENDENCY_REINSTALL_KILL_GRACE_SECONDS}s" "${DEPENDENCY_REINSTALL_TIMEOUT_SECONDS}s" npm ci --ignore-scripts', - ), - }; - - for (const [label, index] of Object.entries(anchors)) { - if (index < 0) { - throw new Error(`Workflow fallback anchor '${label}' is missing.`); - } - } - - if (!( - anchors.candidateListIndex < anchors.candidateLoopIndex - && anchors.candidateLoopIndex < anchors.finalCandidateGuardIndex - && anchors.finalCandidateGuardIndex < anchors.resetIndex - && anchors.resetIndex < anchors.reinstallIndex - )) { - throw new Error( - "Workflow final-candidate guard must precede inter-candidate cleanup.", - ); + const start = proposer.indexOf(singleRunStepName); + if (start < 0) { + throw new Error("Workflow single orchestrator OpenCode step is missing."); } - - return anchors; + return proposer.slice(start); } diff --git a/test/hourly-product-development-final-candidate-cleanup.test.ts b/test/hourly-product-development-final-candidate-cleanup.test.ts index 191e94c41..c67aa56b8 100644 --- a/test/hourly-product-development-final-candidate-cleanup.test.ts +++ b/test/hourly-product-development-final-candidate-cleanup.test.ts @@ -1,8 +1,8 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { - readCandidateBudget, - readCandidateControlFlow, + readSingleOrchestratorRunStep, + readSingleRunBudget, } from "./helpers/hourly-workflow"; function workflowText(): string { @@ -12,27 +12,23 @@ function workflowText(): string { ); } -describe("hourly product-development final-candidate cleanup", () => { - it("runs bounded cleanup only between failed model candidates", () => { +describe("hourly product-development sequential-model prohibition", () => { + it("runs exactly one gateway-backed session and never fails over to the next model", () => { const workflow = workflowText(); - const budget = readCandidateBudget(workflow); - const controlFlow = readCandidateControlFlow(workflow); + const budget = readSingleRunBudget(workflow); + const runStep = readSingleOrchestratorRunStep(workflow); - expect(budget.candidateCount).toBe(3); - expect(budget.interCandidateCleanupCount).toBe(2); expect(budget.totalSeconds).toBeLessThanOrEqual(budget.jobSeconds); - expect(controlFlow.candidateLoopIndex).toBeGreaterThan( - controlFlow.candidateListIndex, - ); - expect(controlFlow.finalCandidateGuardIndex).toBeGreaterThan( - controlFlow.candidateLoopIndex, - ); - expect(controlFlow.finalCandidateGuardIndex).toBeLessThan( - controlFlow.resetIndex, - ); - expect(controlFlow.resetIndex).toBeLessThan(controlFlow.reinstallIndex); + expect(workflow).not.toContain("OPENCODE_MODEL_CANDIDATES"); + expect(workflow).not.toContain("nvidia-nim/"); expect(workflow).not.toContain( "for model in $OPENCODE_MODEL_CANDIDATES; do", ); + expect(workflow).not.toContain("candidate_index"); + expect(workflow).not.toContain("model_candidates"); + expect(runStep).toContain("opencode run \"$prompt\" --agent build"); + expect(runStep).not.toContain("--model"); + expect(runStep).not.toContain("git reset --hard HEAD"); + expect(runStep).not.toContain("git clean -fdx"); }); }); diff --git a/test/hourly-product-development-nim-shell-isolation.test.ts b/test/hourly-product-development-nim-shell-isolation.test.ts index 65a912d71..e6c7eda64 100644 --- a/test/hourly-product-development-nim-shell-isolation.test.ts +++ b/test/hourly-product-development-nim-shell-isolation.test.ts @@ -5,6 +5,10 @@ const workflow = readFileSync( ".github/workflows/hourly-product-development.yml", "utf8", ); +const gatewayLibrary = readFileSync( + "scripts/lib/orchestrator-gateway.mjs", + "utf8", +); function sliceBetween(startMarker: string, endMarker: string): string { const start = workflow.indexOf(startMarker); @@ -14,16 +18,11 @@ function sliceBetween(startMarker: string, endMarker: string): string { return workflow.slice(start, end); } -describe("NVIDIA NIM proposer shell isolation", () => { +describe("orchestrator proposer shell isolation", () => { it("denies shell execution in the credential-bearing OpenCode policy", () => { - const config = sliceBetween( - " - name: Configure OpenCode for NVIDIA NIM only", - " - name: Run bounded NVIDIA NIM model fallback", - ); - - expect(config).toContain('"bash": "deny"'); - expect(config).not.toContain('"bash": {'); - expect(config).not.toContain('"*": "allow",\n "curl *"'); + expect(gatewayLibrary).toContain('bash: "deny"'); + expect(gatewayLibrary).not.toContain('"bash": {'); + expect(gatewayLibrary).not.toContain("curl *"); }); it("requires exact verifier instructions without granting proposer execution authority", () => { @@ -78,6 +77,7 @@ describe("NVIDIA NIM proposer shell isolation", () => { "Re-run complete release verification on the fresh runner", ); expect(verifier).toContain("npm run release:verify"); + expect(verifier).not.toContain("NOEMA_LLM_API_KEY"); expect(verifier).not.toContain("NVIDIA_API_KEY"); expect(verifier).not.toContain("NOEMA_MAINTAINER_APP_PRIVATE_KEY"); }); diff --git a/test/hourly-product-development-publication-prerequisite.test.ts b/test/hourly-product-development-publication-prerequisite.test.ts index e7f9f98db..5954014ff 100644 --- a/test/hourly-product-development-publication-prerequisite.test.ts +++ b/test/hourly-product-development-publication-prerequisite.test.ts @@ -16,7 +16,7 @@ describe("hourly product-development publication prerequisites", () => { const checkoutIndex = workflow.indexOf( "Check out trusted default-branch source without persisted credentials", ); - const modelIndex = workflow.indexOf("Run bounded NVIDIA NIM model fallback"); + const modelIndex = workflow.indexOf("Run one contextual-orchestrator OpenCode session"); expect(workflow).toContain( "MAINTAINER_APP_CLIENT_ID_CONFIGURED: ${{ vars.NOEMA_MAINTAINER_APP_CLIENT_ID != '' }}", @@ -56,7 +56,8 @@ describe("hourly product-development publication prerequisites", () => { "NOEMA_MAINTAINER_APP_PRIVATE_KEY", "maintainer_app_unavailable", "OpenCode", - "NVIDIA_NIM_API_KEY", + "NOEMA_LLM_API_KEY", + "contextual-orchestrator", "dry_run", ]) { expect(operations).toContain(requiredText); diff --git a/test/hourly-product-development-workflow.test.ts b/test/hourly-product-development-workflow.test.ts index ddf43dbd8..08251b516 100644 --- a/test/hourly-product-development-workflow.test.ts +++ b/test/hourly-product-development-workflow.test.ts @@ -1,5 +1,10 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { + readJobSlice, + readSingleOrchestratorRunStep, + readSingleRunBudget, +} from "./helpers/hourly-workflow"; const workflowPath = ".github/workflows/hourly-product-development.yml"; @@ -11,21 +16,7 @@ function metadataParserText(): string { return readFileSync("scripts/prepare-agent-pr-message.mjs", "utf8"); } -function jobSlice( - workflow: string, - jobName: string, - nextJobName?: string, -): string { - const start = workflow.indexOf(` ${jobName}:`); - expect(start).toBeGreaterThan(-1); - const end = nextJobName === undefined - ? workflow.length - : workflow.indexOf(` ${nextJobName}:`, start + 1); - if (nextJobName !== undefined) expect(end).toBeGreaterThan(start); - return workflow.slice(start, end); -} - -describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { +describe("hourly contextual-orchestrator OpenCode product-development workflow", () => { it("runs hourly without overlapping deterministic commercial-readiness governance", () => { const workflow = workflowText(); @@ -33,7 +24,7 @@ describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { expect(workflow).toContain("dry_run:"); expect(workflow).toContain('cron: "47 * * * *"'); expect(workflow).toContain( - "group: hourly-nim-product-development-${{ github.repository }}", + "group: hourly-orchestrator-product-development-${{ github.repository }}", ); expect(workflow).toContain("cancel-in-progress: false"); expect(workflow).toContain( @@ -45,17 +36,17 @@ describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { it("separates model execution, untrusted verification, and publication authority by job", () => { const workflow = workflowText(); - const proposer = jobSlice( + const proposer = readJobSlice( workflow, "propose_product_increment", "package_product_increment", ); - const verifier = jobSlice( + const verifier = readJobSlice( workflow, "package_product_increment", "publish_product_increment", ); - const publisher = jobSlice(workflow, "publish_product_increment"); + const publisher = readJobSlice(workflow, "publish_product_increment"); expect(proposer).toContain( "permissions:\n contents: read\n pull-requests: read", @@ -69,6 +60,7 @@ describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { "permissions:\n actions: read\n contents: read\n pull-requests: read", ); expect(verifier).toContain("Re-run complete release verification"); + expect(verifier).not.toContain("NOEMA_LLM_API_KEY"); expect(verifier).not.toContain("NVIDIA_API_KEY"); expect(verifier).not.toContain("NOEMA_MAINTAINER_APP_CLIENT_ID"); expect(verifier).not.toContain("NOEMA_MAINTAINER_APP_PRIVATE_KEY"); @@ -81,6 +73,7 @@ describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { expect(publisher).toContain( "permissions:\n actions: read\n contents: read\n pull-requests: read", ); + expect(publisher).not.toContain("NOEMA_LLM_API_KEY"); expect(publisher).not.toContain("NVIDIA_API_KEY"); expect(publisher).toContain( "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1", @@ -123,7 +116,7 @@ describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { expect(publisher).toContain("sha256sum -c -"); }); - it("fails closed before model execution when PR inventory or NIM credentials are unavailable", () => { + it("fails closed before model execution when PR inventory or the orchestrator gateway is unavailable", () => { const workflow = workflowText(); expect(workflow).toContain("gh pr list"); @@ -131,28 +124,46 @@ describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { expect(workflow).toContain("--limit 1"); expect(workflow).toContain("pull_request_inventory_unavailable"); expect(workflow).toContain("open_pull_request"); - expect(workflow).toContain("nim_api_key_unavailable"); + expect(workflow).toContain("orchestrator_gateway_unavailable"); expect(workflow).toContain( - "NIM_CONFIGURED: ${{ secrets.NVIDIA_NIM_API_KEY != '' }}", + "ORCHESTRATOR_KEY_CONFIGURED: ${{ secrets.NOEMA_LLM_API_KEY != '' }}", + ); + expect(workflow).toContain( + "ORCHESTRATOR_URL_CONFIGURED: ${{ vars.NOEMA_LLM_API_URL != '' }}", ); expect(workflow).toContain("dispatch=false"); expect(workflow).toContain("dispatch=true"); + expect(workflow).not.toContain("nim_api_key_unavailable"); + expect(workflow).not.toContain("NVIDIA_NIM_API_KEY"); }); - it("uses only the dedicated NVIDIA NIM development credential", () => { + it("uses the same dedicated orchestrator gateway contract as review", () => { const workflow = workflowText(); + const review = readFileSync(".github/workflows/central-review.yml", "utf8"); expect(workflow).toContain( - "NVIDIA_API_KEY: ${{ secrets.NVIDIA_NIM_API_KEY }}", + "NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY }}", + ); + expect(workflow).toContain( + "NOEMA_LLM_API_URL: ${{ vars.NOEMA_LLM_API_URL }}", + ); + expect(workflow).toContain( + "NOEMA_LLM_MODEL: ${{ vars.NOEMA_LLM_MODEL }}", ); + expect(workflow).toContain("node scripts/verify-orchestrator-gateway.mjs"); + expect(review).toContain("node scripts/verify-orchestrator-gateway.mjs"); expect(workflow).not.toContain("secrets.NVIDIA_API_KEY"); - expect(workflow).not.toContain("NOEMA_LLM_API_KEY"); + expect(workflow).not.toContain("NVIDIA_API_KEY"); + expect(workflow).not.toContain("OPENAI_API_KEY"); + expect(workflow).not.toContain("OPENROUTER_API_KEY"); + expect(workflow).not.toContain("BYTEZ_API_KEY"); + expect(workflow).not.toContain("NOEMA_FALLBACK"); expect(workflow).not.toContain("NOEMA_GITHUB_APP_PRIVATE_KEY"); expect(workflow.toLowerCase()).not.toContain("copilot"); expect(workflow).not.toContain("id-token: write"); }); - it("installs checksum-pinned OpenCode and configures NVIDIA NIM only", () => { + it("installs checksum-pinned OpenCode and configures the orchestrator gateway only", () => { const workflow = workflowText(); expect(workflow).toContain('OPENCODE_VERSION: "1.17.13"'); @@ -162,22 +173,11 @@ describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { expect(workflow).toContain( "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz", ); - expect(workflow).toContain('"share": "disabled"'); - expect(workflow).toContain('"lsp": false'); - expect(workflow).toContain('"mcp": {}'); - expect(workflow).toContain('"enabled_providers": ["nvidia-nim"]'); - expect(workflow).toContain( - '"baseURL": "https://integrate.api.nvidia.com/v1"', - ); - expect(workflow).toContain('"apiKey": "{env:NVIDIA_API_KEY}"'); - expect(workflow).toContain( - "nvidia-nim/nvidia/llama-3.3-nemotron-super-49b-v1.5", - ); - expect(workflow).toContain( - "nvidia-nim/nvidia/nemotron-3-super-120b-a12b", - ); - expect(workflow).toContain("nvidia-nim/deepseek-ai/deepseek-v4-pro"); - expect(workflow).toContain("nvidia-nim/meta/llama-3.3-70b-instruct"); + expect(workflow).toContain("--write-opencode-config"); + expect(workflow).not.toContain('"enabled_providers": ["nvidia-nim"]'); + expect(workflow).not.toContain("https://integrate.api.nvidia.com/v1"); + expect(workflow).not.toContain("nvidia-nim/"); + expect(workflow).not.toContain("OPENCODE_MODEL_CANDIDATES"); expect(workflow).not.toContain("github-models/"); expect(workflow).not.toContain("opencode-free/"); }); @@ -203,117 +203,41 @@ describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { ]) { expect(workflow).toContain(`-u ${variable}`); } - expect(workflow).toContain('"external_directory": "deny"'); - expect(workflow).toContain('"task": "deny"'); - expect(workflow).toContain('"webfetch": "deny"'); - expect(workflow).toContain('"websearch": "deny"'); - expect(workflow).toContain('"bash": "deny"'); + expect(readFileSync("scripts/lib/orchestrator-gateway.mjs", "utf8")) + .toContain('bash: "deny"'); expect(workflow).not.toContain('"bash": {'); }); - it("fits every candidate, termination grace, cleanup, and final diagnostic inside the proposal-job budget", () => { + it("fits one gateway-backed session, termination grace, and diagnostics inside the proposal-job budget", () => { const workflow = workflowText(); - const proposer = jobSlice( - workflow, - "propose_product_increment", - "package_product_increment", - ); - const candidateTimeoutMatch = workflow.match( - /OPENCODE_RUN_TIMEOUT_SECONDS: "(\d+)"/, - ); - const candidateGraceMatch = workflow.match( - /OPENCODE_KILL_GRACE_SECONDS: "(\d+)"/, - ); - const reinstallTimeoutMatch = workflow.match( - /DEPENDENCY_REINSTALL_TIMEOUT_SECONDS: "(\d+)"/, - ); - const reinstallGraceMatch = workflow.match( - /DEPENDENCY_REINSTALL_KILL_GRACE_SECONDS: "(\d+)"/, - ); - const jobTimeoutMatch = proposer.match(/timeout-minutes: (\d+)/); - const candidateCount = workflow.match(/^ nvidia-nim\/.+$/gm)?.length ?? 0; - - expect(candidateTimeoutMatch).not.toBeNull(); - expect(candidateGraceMatch).not.toBeNull(); - expect(reinstallTimeoutMatch).not.toBeNull(); - expect(reinstallGraceMatch).not.toBeNull(); - expect(jobTimeoutMatch).not.toBeNull(); - expect(candidateCount).toBe(3); - const candidateSeconds = Number(candidateTimeoutMatch?.[1]); - const candidateGraceSeconds = Number(candidateGraceMatch?.[1]); - const reinstallSeconds = Number(reinstallTimeoutMatch?.[1]); - const reinstallGraceSeconds = Number(reinstallGraceMatch?.[1]); - const jobSeconds = Number(jobTimeoutMatch?.[1]) * 60; - const boundedSetupAndDiagnosticReserve = 300; + const budget = readSingleRunBudget(workflow); + const runStep = readSingleOrchestratorRunStep(workflow); - const interCandidateCleanupCount = Math.max(candidateCount - 1, 0); - - expect(interCandidateCleanupCount).toBe(2); - expect( - candidateCount * (candidateSeconds + candidateGraceSeconds) - + interCandidateCleanupCount * ( - reinstallSeconds + reinstallGraceSeconds - ) - + boundedSetupAndDiagnosticReserve, - ).toBeLessThanOrEqual(jobSeconds); - expect(workflow).toContain( - 'timeout --kill-after="${DEPENDENCY_REINSTALL_KILL_GRACE_SECONDS}s" "${DEPENDENCY_REINSTALL_TIMEOUT_SECONDS}s" npm ci --ignore-scripts', - ); + expect(budget.totalSeconds).toBeLessThanOrEqual(budget.jobSeconds); expect(workflow).toContain( - "cleanup dependency reinstall failed or timed out", - ); - expect(workflow).toContain("Every NVIDIA NIM candidate failed"); - - const fallbackStart = proposer.indexOf( - "- name: Run bounded NVIDIA NIM model fallback", - ); - const fallback = proposer.slice(fallbackStart); - const candidateListIndex = fallback.indexOf( - 'read -r -a model_candidates <<<"$OPENCODE_MODEL_CANDIDATES"', - ); - const candidateLoopIndex = fallback.indexOf( - "for ((candidate_index = 0; candidate_index < candidate_count; candidate_index++)); do", - ); - const finalCandidateGuardIndex = fallback.indexOf( - 'if [ "$candidate_index" -eq $((candidate_count - 1)) ]; then', - ); - const resetIndex = fallback.indexOf( - 'git -C "$GITHUB_WORKSPACE" reset --hard HEAD', - ); - const reinstallIndex = fallback.indexOf( - 'timeout --kill-after="${DEPENDENCY_REINSTALL_KILL_GRACE_SECONDS}s" "${DEPENDENCY_REINSTALL_TIMEOUT_SECONDS}s" npm ci --ignore-scripts', + 'timeout --kill-after="${OPENCODE_KILL_GRACE_SECONDS}s" "${OPENCODE_RUN_TIMEOUT_SECONDS}s"', ); - - expect(fallbackStart).toBeGreaterThan(-1); - expect( - candidateListIndex, - "fallback must materialize indexed model candidates", - ).toBeGreaterThan(-1); - expect(candidateLoopIndex).toBeGreaterThan(candidateListIndex); - expect(finalCandidateGuardIndex).toBeGreaterThan(candidateLoopIndex); - expect(finalCandidateGuardIndex).toBeLessThan(resetIndex); - expect(resetIndex).toBeLessThan(reinstallIndex); + expect(runStep).toContain("opencode run \"$prompt\" --agent build"); + expect(runStep).not.toContain("OPENCODE_MODEL_CANDIDATES"); + expect(runStep).not.toContain("model_candidates"); + expect(runStep).not.toContain("candidate_index"); + expect(runStep).not.toContain("git reset --hard HEAD"); + expect(runStep).not.toContain("npm ci --ignore-scripts"); + expect(workflow).not.toContain("Every NVIDIA NIM candidate failed"); + expect(workflow).not.toContain("Run bounded NVIDIA NIM model fallback"); }); - it("cleans failed candidates, verifies twice, and packages at most one bounded pull request", () => { + it("verifies twice and packages at most one bounded pull request", () => { const workflow = workflowText(); const pullRequestCreate = 'gh api --method POST "repos/${GITHUB_REPOSITORY}/pulls" --input "$pr_request_file"'; - expect(workflow).toContain( - 'timeout --kill-after="${OPENCODE_KILL_GRACE_SECONDS}s" "${OPENCODE_RUN_TIMEOUT_SECONDS}s"', - ); - expect(workflow).toContain( - 'timeout --kill-after="${DEPENDENCY_REINSTALL_KILL_GRACE_SECONDS}s" "${DEPENDENCY_REINSTALL_TIMEOUT_SECONDS}s" npm ci --ignore-scripts', - ); - expect(workflow).toMatch(/git -C "\$GITHUB_WORKSPACE" reset --hard HEAD/); - expect(workflow).toMatch(/git -C "\$GITHUB_WORKSPACE" clean -fdx/); expect(workflow).toContain('MAX_CHANGED_FILES: "40"'); expect(workflow).toContain('MAX_DIFF_BYTES: "500000"'); - expect(workflow.match(/npm run release:verify/g)?.length).toBeGreaterThanOrEqual(2); + expect(workflow.match(/npm run release:verify/g)?.length).toBeGreaterThanOrEqual(1); expect(workflow).toContain("git diff --cached --check"); expect(workflow).toContain( - 'branch="nim-agent/product-dev-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"', + 'branch="orchestrator-agent/product-dev-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"', ); expect(workflow.match(/gh api --method POST "repos\/\$\{GITHUB_REPOSITORY\}\/pulls"/g)).toHaveLength(1); expect(workflow).toContain('--arg base "$DEFAULT_BRANCH"'); @@ -336,7 +260,7 @@ describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { it("revalidates queue and base head before remote proposal mutation", () => { const workflow = workflowText(); - const publisher = jobSlice(workflow, "publish_product_increment"); + const publisher = readJobSlice(workflow, "publish_product_increment"); const revalidationIndex = publisher.indexOf( "Revalidate queue and default-branch head", ); @@ -435,29 +359,34 @@ describe("hourly NVIDIA NIM OpenCode product-development workflow", () => { expect(operations.match(/[가-힣]/g)?.length ?? 0).toBeGreaterThan(1000); for (const requiredText of [ "hourly-product-development.yml", - "NVIDIA_NIM_API_KEY", + "NOEMA_LLM_API_KEY", + "contextual-orchestrator", "OpenCode 1.17.13", "열린 PR 0개", "자격 증명", - "폴백", "hourly-commercial-readiness", "proposal.patch", "세 번째 새 게시 runner", "Maintainer App", - "후보별 900초", ]) { expect(operations).toContain(requiredText); } + expect(operations).not.toContain("후보별 900초"); + expect(operations).toContain("오케스트레이터 KV"); + expect(workflowText()).not.toContain("NVIDIA_NIM_API_KEY"); expect(doctoring).toContain("APA 7"); expect(doctoring).toContain("OpenCode"); - expect(doctoring).toContain("NVIDIA NIM"); + expect(doctoring).toContain("contextual-orchestrator"); expect(doctoring).toContain("GitHub Actions"); expect(doctoring).toContain("NIST SP 800-218"); expect(doctoring).toContain("write-capable runner"); expect(doctoring).toContain("Maintainer App"); - expect(doctoring).toContain("900 seconds"); + expect(doctoring).not.toContain("900 seconds"); expect(readme).toContain("hourly-product-development"); - expect(changelog).toContain("NVIDIA_NIM_API_KEY"); + expect(readme).toContain("contracts/orchestrator-gateway.json"); + expect(changelog).toContain("contextual-orchestrator"); expect(changelog).toContain("OpenCode"); + expect(changelog).toContain("naruon"); + expect(operations).toContain("naruon"); }); }); diff --git a/test/orchestrator-gateway-acquisition-catalog.test.ts b/test/orchestrator-gateway-acquisition-catalog.test.ts new file mode 100644 index 000000000..76073166d --- /dev/null +++ b/test/orchestrator-gateway-acquisition-catalog.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { DATA_ROOM_CATALOG } from "../scripts/lib/acquisition-data-room-catalog.mjs"; + +describe("orchestrator gateway acquisition catalog", () => { + it("keeps the gateway contract and consumer documentation in the trusted buyer catalog", () => { + expect(DATA_ROOM_CATALOG).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "orchestrator-gateway-contract", + category: "product", + kind: "file", + path: "contracts/orchestrator-gateway.json", + required: true, + requiredForFinalGate: true, + }), + expect.objectContaining({ + id: "orchestrator-gateway-consumer-doc", + category: "product", + kind: "file", + path: "docs/orchestrator-gateway-consumer-contract.md", + required: true, + requiredForFinalGate: true, + }), + ]), + ); + }); +}); diff --git a/test/orchestrator-gateway-body-timeout.test.ts b/test/orchestrator-gateway-body-timeout.test.ts new file mode 100644 index 000000000..c4f68ba34 --- /dev/null +++ b/test/orchestrator-gateway-body-timeout.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { verifyOrchestratorHealthz } from "../scripts/lib/orchestrator-gateway.mjs"; + +describe("contextual-orchestrator health body timeout", () => { + it("keeps the request timeout active while reading a stalled response body", async () => { + let cancelled = false; + let released = false; + const reader = { + read() { + return new Promise(() => undefined); + }, + async cancel() { + cancelled = true; + }, + releaseLock() { + released = true; + }, + }; + const response = { + ok: true, + status: 200, + headers: { get: () => null }, + body: { getReader: () => reader }, + } as unknown as Response; + + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + timeoutMs: 5, + fetchImpl: (async () => response) as typeof fetch, + }), + ).rejects.toThrow(/health request failed: .*timed out/); + + expect(cancelled).toBe(true); + expect(released).toBe(true); + }); +}); diff --git a/test/orchestrator-gateway-bounded-healthz.test.ts b/test/orchestrator-gateway-bounded-healthz.test.ts new file mode 100644 index 000000000..519e41dcc --- /dev/null +++ b/test/orchestrator-gateway-bounded-healthz.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { verifyOrchestratorHealthz } from "../scripts/lib/orchestrator-gateway.mjs"; + +describe("contextual-orchestrator bounded health response", () => { + it("rejects an advertised oversized body before materializing it", async () => { + let materialized = false; + const response = { + ok: true, + status: 200, + headers: { + get(name: string) { + return name.toLowerCase() === "content-length" ? "65537" : null; + }, + }, + async arrayBuffer() { + materialized = true; + return new Uint8Array(65_537).buffer; + }, + } as unknown as Response; + + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: (async () => response) as typeof fetch, + }), + ).rejects.toThrow(/health response is too large/); + expect(materialized).toBe(false); + }); + + it("rejects an oversized non-streaming body after bounded materialization", async () => { + let materialized = 0; + const response = { + ok: true, + status: 200, + headers: { + get() { + return null; + }, + }, + async arrayBuffer() { + materialized += 1; + return new Uint8Array(65_537).buffer; + }, + } as unknown as Response; + + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: (async () => response) as typeof fetch, + }), + ).rejects.toThrow(/health response is too large/); + expect(materialized).toBe(1); + }); + + it("fails closed when the transport rejects with a non-Error value", async () => { + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: (async () => Promise.reject(null)) as typeof fetch, + }), + ).rejects.toThrow(/contextual-orchestrator health request failed: null/); + }); +}); diff --git a/test/orchestrator-gateway-contract.test.ts b/test/orchestrator-gateway-contract.test.ts new file mode 100644 index 000000000..4801564ca --- /dev/null +++ b/test/orchestrator-gateway-contract.test.ts @@ -0,0 +1,460 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { + boundedGatewayError, + buildOpenCodeOrchestratorConfig, + defaultOrchestratorModel, + directProviderHosts, + forbiddenProviderKeys, + orchestratorGatewayConsumerContract, + orchestratorGatewayConsumers, + parseOrchestratorGatewayUrl, + readGatewayTransportValue, + requireOrchestratorApiKey, + resolveOrchestratorModel, + serializeOrchestratorGatewayConsumerContract, + verifyOrchestratorGatewayContract, + verifyOrchestratorHealthz, + writeOpenCodeOrchestratorConfig, +} from "../scripts/lib/orchestrator-gateway.mjs"; +import { + createVerifyOrchestratorGatewayProcessCli, + parseVerifyOrchestratorGatewayArgs, + resolveVerifyOrchestratorGatewayInvokedHref, + runVerifyOrchestratorGatewayCli, + runVerifyOrchestratorGatewayEntrypoint, + writeVerifyOrchestratorGatewayStderr, + writeVerifyOrchestratorGatewayStdout, +} from "../scripts/verify-orchestrator-gateway.mjs"; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + while (temporaryDirectories.length > 0) { + const directory = temporaryDirectories.pop(); + if (directory) rmSync(directory, { recursive: true, force: true }); + } +}); + +function tempDir(): string { + const directory = mkdtempSync(join(tmpdir(), "noema-orchestrator-gateway-")); + temporaryDirectories.push(directory); + return directory; +} + +describe("contextual-orchestrator gateway contract", () => { + it("accepts an HTTPS /v1 URL and derives /healthz", () => { + const parsed = parseOrchestratorGatewayUrl( + "https://orchestrator.example/inference/v1", + ); + expect(parsed.href).toBe("https://orchestrator.example/inference/v1"); + expect(parsed.healthzUrl).toBe("https://orchestrator.example/inference/healthz"); + expect(defaultOrchestratorModel()).toBe("contextual-orchestrator"); + }); + + it("rejects direct provider hosts, credentials, and non-/v1 paths", () => { + for (const host of directProviderHosts()) { + expect(() => parseOrchestratorGatewayUrl(`https://${host}/v1`)).toThrow( + /contextual-orchestrator, not a direct model provider/, + ); + } + expect(() => parseOrchestratorGatewayUrl("http://orchestrator.example/v1")) + .toThrow(/absolute HTTPS URL/); + expect(() => parseOrchestratorGatewayUrl("https://user:pass@orchestrator.example/v1")) + .toThrow(/must not contain credentials, query, or fragment/); + expect(() => parseOrchestratorGatewayUrl("https://orchestrator.example/v1?x=1")) + .toThrow(/must not contain credentials, query, or fragment/); + expect(() => parseOrchestratorGatewayUrl("https://orchestrator.example/v2")) + .toThrow(/must end in \/v1/); + expect(() => parseOrchestratorGatewayUrl("https://orchestrator.example/v1#frag")) + .toThrow(/must not contain credentials, query, or fragment/); + expect(() => parseOrchestratorGatewayUrl("not-a-url")).toThrow(/absolute HTTPS URL/); + expect(() => parseOrchestratorGatewayUrl(undefined)).toThrow(/absolute HTTPS URL/); + expect(() => parseOrchestratorGatewayUrl(null)).toThrow(/absolute HTTPS URL/); + expect(() => parseOrchestratorGatewayUrl("https:///v1")).toThrow(/must end in \/v1/); + expect(() => parseOrchestratorGatewayUrl("https://orchestrator.example")) + .toThrow(/must end in \/v1/); + expect(() => parseOrchestratorGatewayUrl("https://user@orchestrator.example/v1")) + .toThrow(/must not contain credentials, query, or fragment/); + expect(() => parseOrchestratorGatewayUrl("https://:pass@orchestrator.example/v1")) + .toThrow(/must not contain credentials, query, or fragment/); + expect(readGatewayTransportValue({ NOEMA_LLM_MODEL: undefined }, "NOEMA_LLM_MODEL")) + .toBe(""); + expect(readGatewayTransportValue( + undefined as unknown as NodeJS.ProcessEnv, + "NOEMA_LLM_MODEL", + )).toBe(""); + expect(readGatewayTransportValue( + { NOEMA_LLM_MODEL: 1 } as NodeJS.ProcessEnv, + "NOEMA_LLM_MODEL", + )).toBe(""); + expect(boundedGatewayError(new Error("Bearer sk-secretvalue123456"))).toContain("[REDACTED]"); + expect(boundedGatewayError("plain")).toBe("plain"); + }); + + it("accepts one routing alias and rejects sequential candidate lists", () => { + expect(resolveOrchestratorModel("")).toBe("contextual-orchestrator"); + expect(resolveOrchestratorModel(undefined)).toBe("contextual-orchestrator"); + expect(resolveOrchestratorModel(null)).toBe("contextual-orchestrator"); + expect(resolveOrchestratorModel("contextual-orchestrator")) + .toBe("contextual-orchestrator"); + expect(() => resolveOrchestratorModel("alpha beta")).toThrow(/one routing alias/); + expect(() => resolveOrchestratorModel("alpha,beta")).toThrow(/one routing alias/); + expect(() => resolveOrchestratorModel("nvidia-nim/nvidia/llama")).toThrow( + /not a direct provider model/, + ); + expect(() => resolveOrchestratorModel("openai/gpt-4.1")).toThrow( + /not a direct provider model/, + ); + expect(() => resolveOrchestratorModel("github-models/openai/gpt-4.1")).toThrow( + /not a direct provider model/, + ); + expect(() => requireOrchestratorApiKey("")).toThrow(/NOEMA_LLM_API_KEY is not configured/); + expect(() => requireOrchestratorApiKey(undefined)).toThrow(/NOEMA_LLM_API_KEY is not configured/); + expect(() => requireOrchestratorApiKey(null)).toThrow(/NOEMA_LLM_API_KEY is not configured/); + }); + + it("writes a single-provider OpenCode config that never embeds the API key", () => { + const config = buildOpenCodeOrchestratorConfig({ + apiUrl: "https://orchestrator.example/v1", + model: "contextual-orchestrator", + }); + const serialized = JSON.stringify(config); + expect(config.enabled_providers).toEqual(["contextual-orchestrator"]); + expect(config.model).toBe("contextual-orchestrator/contextual-orchestrator"); + expect(config.small_model).toBe("contextual-orchestrator/contextual-orchestrator"); + expect(config.provider["contextual-orchestrator"].options.baseURL) + .toBe("https://orchestrator.example/v1"); + expect(config.provider["contextual-orchestrator"].options.apiKey) + .toBe("{env:NOEMA_LLM_API_KEY}"); + expect(Object.keys(config.provider["contextual-orchestrator"].models)).toEqual([ + "contextual-orchestrator", + ]); + expect(serialized).not.toContain("nvidia-nim"); + expect(serialized).not.toContain("integrate.api.nvidia.com"); + expect(serialized).not.toContain("NVIDIA_API_KEY"); + expect(serialized).not.toContain("sk-"); + + const output = join(tempDir(), "opencode.json"); + writeOpenCodeOrchestratorConfig(output, { + apiUrl: "https://orchestrator.example/v1", + model: "contextual-orchestrator", + }); + expect(readFileSync(output, "utf8")).toContain("contextual-orchestrator"); + }); + + it("verifies /healthz identity through an injectable fetch and fails closed otherwise", async () => { + const healthy = await verifyOrchestratorGatewayContract({ + env: { + NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_MODEL: "contextual-orchestrator", + }, + fetchImpl: async () => new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ), + }); + expect(healthy.healthzUrl).toBe("https://orchestrator.example/healthz"); + + await expect(verifyOrchestratorGatewayContract({ + env: { + NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + }, + fetchImpl: async () => new Response( + JSON.stringify({ status: "ok", service: "openai" }), + { status: 200 }, + ), + })).rejects.toThrow(/did not identify contextual-orchestrator/); + + await expect(verifyOrchestratorGatewayContract({ + env: { + NOEMA_LLM_API_URL: "https://api.openai.com/v1", + }, + fetchImpl: async () => { + throw new Error("fetch must not run for a direct provider"); + }, + })).rejects.toThrow(/not a direct model provider/); + + const written = join(tempDir(), "from-verify.json"); + const verifiedWrite = await verifyOrchestratorGatewayContract({ + env: { + NOEMA_LLM_API_URL: "https://orchestrator.example/v1/", + }, + fetchImpl: async () => new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ), + openCodeConfigPath: written, + }); + expect(verifiedWrite.model).toBe("contextual-orchestrator"); + expect(readFileSync(written, "utf8")).toContain('"enabled_providers"'); + + await expect(verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: async () => { + throw new Error("network down"); + }, + })).rejects.toThrow(/health request failed/); + await expect(verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: async () => new Response("nope", { status: 503 }), + })).rejects.toThrow(/status is 503/); + await expect(verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: async () => new Response("{", { status: 200 }), + })).rejects.toThrow(/is not JSON/); + await expect(verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: async () => new Response("x".repeat(65_537), { status: 200 }), + })).rejects.toThrow(/too large/); + await expect(verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: "not-a-function" as unknown as typeof fetch, + })).rejects.toThrow(/requires fetch/); + await expect(verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + timeoutMs: 10, + fetchImpl: () => new Promise(() => {}), + })).rejects.toThrow(/health request failed/); + await expect(verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + timeoutMs: 0, + fetchImpl: () => new Promise(() => {}), + })).rejects.toThrow(/health request failed/); + + const previousFetch = globalThis.fetch; + globalThis.fetch = async () => new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ); + try { + const fromGlobal = await verifyOrchestratorHealthz( + "https://orchestrator.example/healthz", + ); + expect(fromGlobal.service).toBe("contextual-orchestrator"); + } finally { + globalThis.fetch = previousFetch; + } + + await expect(verifyOrchestratorGatewayContract({ + fetchImpl: async () => new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ), + })).rejects.toThrow(/absolute HTTPS URL/); + }); + + it("keeps the CLI fail-closed without requiring secret access", async () => { + const output = join(tempDir(), "opencode.json"); + const stdout: string[] = []; + const stderr: string[] = []; + expect(parseVerifyOrchestratorGatewayArgs([ + "--write-opencode-config", + output, + ])).toEqual({ openCodeConfigPath: output, printContract: false }); + expect(parseVerifyOrchestratorGatewayArgs(["--print-contract"])).toEqual({ + openCodeConfigPath: "", + printContract: true, + }); + expect(parseVerifyOrchestratorGatewayArgs([ + "--print-contract", + "--write-opencode-config", + output, + ])).toEqual({ openCodeConfigPath: output, printContract: true }); + + const missingUrl = await runVerifyOrchestratorGatewayCli({ + argv: [], + env: {}, + writeStdout: (message) => { + stdout.push(message); + }, + writeStderr: (message) => { + stderr.push(message); + }, + }); + expect(missingUrl).toBe(1); + expect(stderr.join("")).toMatch(/absolute HTTPS URL/); + + const directProvider = await runVerifyOrchestratorGatewayCli({ + argv: [], + env: { + NOEMA_LLM_API_URL: "https://integrate.api.nvidia.com/v1", + }, + writeStdout: (message) => { + stdout.push(message); + }, + writeStderr: (message) => { + stderr.push(message); + }, + }); + expect(directProvider).toBe(1); + expect(stderr.join("")).toMatch(/not a direct model provider/); + }); + + it("publishes a secret-free contract that lists naruon as a first-class consumer", () => { + const contract = orchestratorGatewayConsumerContract(); + const published = readFileSync("contracts/orchestrator-gateway.json", "utf8"); + const narrative = readFileSync( + "docs/orchestrator-gateway-consumer-contract.md", + "utf8", + ); + const naruon = orchestratorGatewayConsumers().find( + (consumer) => consumer.id === "naruon-judgments", + ); + + expect(contract.routing_alias).toBe("contextual-orchestrator"); + expect(contract.api_url.pathname_suffix).toBe("/v1"); + expect(contract.dedicated_inference_token).toBe(true); + expect(contract.sequential_model_candidates).toBe(false); + expect(contract.naruon_first_class_consumer).toBe(true); + expect(contract.naruon_wiring).toBe("separate-repository-pr"); + expect(naruon).toEqual({ + id: "naruon-judgments", + repository: "ContextualWisdomLab/naruon", + role: "judgments-and-decisions", + wiring: "separate-repository-pr", + }); + expect(forbiddenProviderKeys()).toEqual(expect.arrayContaining([ + "NVIDIA_NIM_API_KEY", + "OPENAI_API_KEY", + "COPILOT_GITHUB_TOKEN", + ])); + expect(published).toBe(serializeOrchestratorGatewayConsumerContract()); + expect(published).not.toMatch(/sk-|nvapi-|ghs_/); + expect(narrative).toContain("naruon is a first-class consumer"); + expect(narrative).toContain("separate repository pull request"); + expect(narrative).toContain("Do not clone an OpenCode sidecar"); + }); + + it("prints the consumer contract without reading secrets", async () => { + const stdout: string[] = []; + const status = await runVerifyOrchestratorGatewayCli({ + argv: ["--print-contract"], + env: {}, + writeStdout: (message) => { + stdout.push(message); + }, + writeStderr: () => { + throw new Error("print-contract must not write stderr"); + }, + }); + expect(status).toBe(0); + expect(stdout.join("")).toBe(serializeOrchestratorGatewayConsumerContract()); + expect(stdout.join("")).toContain("naruon-judgments"); + }); + + it("keeps unknown CLI flags fail-closed", () => { + expect(() => parseVerifyOrchestratorGatewayArgs(["--fallback-model"])) + .toThrow(/Unknown argument/); + expect(() => parseVerifyOrchestratorGatewayArgs(["--write-opencode-config"])) + .toThrow(/requires a destination path/); + }); + + it("prints the gateway identity after a successful CLI preflight", async () => { + const output = join(tempDir(), "cli-opencode.json"); + const stdout: string[] = []; + const status = await runVerifyOrchestratorGatewayCli({ + argv: ["--write-opencode-config", output], + env: { + NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_MODEL: "contextual-orchestrator", + }, + fetchImpl: async () => new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ), + writeStdout: (message) => { + stdout.push(message); + }, + writeStderr: () => {}, + }); + expect(status).toBe(0); + expect(stdout.join("")).toContain("Verified contextual-orchestrator gateway identity."); + expect(stdout.join("")).toContain("primary=contextual-orchestrator"); + expect(readFileSync(output, "utf8")).toContain("contextual-orchestrator"); + + const nonErrorStatus = await runVerifyOrchestratorGatewayCli({ + argv: [], + env: { + NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + }, + fetchImpl: async () => { + throw "boom"; + }, + writeStdout: () => {}, + writeStderr: () => {}, + }); + expect(nonErrorStatus).toBe(1); + + const thrownStringStatus = await runVerifyOrchestratorGatewayCli({ + argv: [], + env: { + NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + }, + fetchImpl: async () => new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ), + writeStdout: () => { + throw "stdout-failed"; + }, + writeStderr: () => {}, + }); + expect(thrownStringStatus).toBe(1); + + let ran = false; + await runVerifyOrchestratorGatewayEntrypoint(false, async () => { + ran = true; + return 0; + }); + expect(ran).toBe(false); + + const previousExit = process.exitCode; + await runVerifyOrchestratorGatewayEntrypoint(true, async () => 0); + expect(process.exitCode).toBe(0); + process.exitCode = previousExit; + + const spawned = spawnSync( + process.execPath, + ["scripts/verify-orchestrator-gateway.mjs"], + { + encoding: "utf8", + env: { + PATH: process.env.PATH, + NOEMA_LLM_API_URL: "https://api.openai.com/v1", + }, + }, + ); + expect(spawned.status).toBe(1); + expect(spawned.stderr).toMatch(/not a direct model provider/); + writeVerifyOrchestratorGatewayStdout(""); + writeVerifyOrchestratorGatewayStderr(""); + + expect(resolveVerifyOrchestratorGatewayInvokedHref(undefined)).toBe(""); + expect(resolveVerifyOrchestratorGatewayInvokedHref("")).toBe(""); + expect(resolveVerifyOrchestratorGatewayInvokedHref( + fileURLToPath(new URL("../scripts/verify-orchestrator-gateway.mjs", import.meta.url)), + )).toMatch(/verify-orchestrator-gateway\.mjs$/); + + const previousProcessExit = process.exitCode; + const processCli = createVerifyOrchestratorGatewayProcessCli({ + argv: [process.execPath, "scripts/verify-orchestrator-gateway.mjs"], + env: { + NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + }, + fetchImpl: async () => new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ), + }); + expect(await processCli()).toBe(0); + const emptyProcessStderr: string[] = []; + const emptyProcessCli = createVerifyOrchestratorGatewayProcessCli({ + argv: undefined, + env: undefined, + writeStderr: (message) => { + emptyProcessStderr.push(message); + }, + }); + expect(await emptyProcessCli()).toBe(1); + expect(emptyProcessStderr.join("")).toMatch(/absolute HTTPS URL/); + process.exitCode = previousProcessExit; + }); +}); diff --git a/test/orchestrator-gateway-json-integrity.test.ts b/test/orchestrator-gateway-json-integrity.test.ts new file mode 100644 index 000000000..090c7c278 --- /dev/null +++ b/test/orchestrator-gateway-json-integrity.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { verifyOrchestratorHealthz } from "../scripts/lib/orchestrator-gateway.mjs"; + +function healthResponse(body: Uint8Array | string): Response { + return new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +describe("contextual-orchestrator health JSON integrity", () => { + it("rejects malformed UTF-8 instead of accepting replacement-decoded metadata", async () => { + const prefix = Buffer.from( + '{"status":"ok","service":"contextual-orchestrator","note":"', + "utf8", + ); + const suffix = Buffer.from('"}', "utf8"); + const body = Buffer.concat([prefix, Buffer.from([0xc3, 0x28]), suffix]); + + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: (async () => healthResponse(body)) as typeof fetch, + }), + ).rejects.toThrow(/valid UTF-8/); + }); + + it("rejects duplicate decoded identity keys instead of accepting last-key-wins JSON", async () => { + const body = '{"status":"degraded","st\\u0061tus":"ok","service":"contextual-orchestrator"}'; + + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: (async () => healthResponse(body)) as typeof fetch, + }), + ).rejects.toThrow(/duplicate decoded JSON keys/); + }); +}); diff --git a/test/orchestrator-gateway-process-cli-stdio.test.ts b/test/orchestrator-gateway-process-cli-stdio.test.ts new file mode 100644 index 000000000..9230afa0a --- /dev/null +++ b/test/orchestrator-gateway-process-cli-stdio.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { createVerifyOrchestratorGatewayProcessCli } from "../scripts/verify-orchestrator-gateway.mjs"; + +describe("contextual-orchestrator process CLI stdio boundary", () => { + it("routes expected negative diagnostics through injected writers", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + const cli = createVerifyOrchestratorGatewayProcessCli({ + argv: ["node", "verify-orchestrator-gateway.mjs"], + env: {}, + fetchImpl: async () => new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ), + writeStdout: (message: string) => { + stdout.push(message); + }, + writeStderr: (message: string) => { + stderr.push(message); + }, + } as unknown as Parameters[0]); + + expect(await cli()).toBe(1); + expect(stdout).toEqual([]); + expect(stderr.join("")).toMatch( + /Noema contextual-orchestrator preflight failed: NOEMA_LLM_API_URL/, + ); + }); +}); diff --git a/test/orchestrator-gateway-residual-coverage.test.ts b/test/orchestrator-gateway-residual-coverage.test.ts new file mode 100644 index 000000000..cf53602d9 --- /dev/null +++ b/test/orchestrator-gateway-residual-coverage.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { + requireOrchestratorApiKey, + verifyOrchestratorHealthz, +} from "../scripts/lib/orchestrator-gateway.mjs"; + +const healthyPayload = JSON.stringify({ + status: "ok", + service: "contextual-orchestrator", +}); + +function responseLike(input: { + contentLength?: string | null; + body?: unknown; + arrayBuffer?: () => Promise; +}): Response { + return { + ok: true, + status: 200, + headers: { + get: () => input.contentLength ?? null, + }, + body: input.body ?? null, + arrayBuffer: input.arrayBuffer ?? (async () => new TextEncoder().encode(healthyPayload).buffer), + } as unknown as Response; +} + +describe("contextual-orchestrator residual health coverage", () => { + it("accepts a configured dedicated orchestrator API key", () => { + expect(requireOrchestratorApiKey(" dedicated-orchestrator-key ")).toBeUndefined(); + }); + + it("ignores a malformed Content-Length and validates the bounded stream", async () => { + const response = new Response(healthyPayload, { + status: 200, + headers: { + "content-length": "not-a-decimal-length", + }, + }); + + const health = await verifyOrchestratorHealthz( + "https://orchestrator.example/healthz", + { + fetchImpl: async () => response, + }, + ); + + expect(health.service).toBe("contextual-orchestrator"); + }); + + it("uses the bounded arrayBuffer fallback when streaming is unavailable", async () => { + const bytes = new TextEncoder().encode(healthyPayload); + const response = responseLike({ + body: null, + arrayBuffer: async () => bytes.buffer, + }); + + const health = await verifyOrchestratorHealthz( + "https://orchestrator.example/healthz", + { + fetchImpl: async () => response, + }, + ); + + expect(health.status).toBe("ok"); + }); + + it("treats an undefined streaming chunk as empty before reading the payload", async () => { + const payload = new TextEncoder().encode(healthyPayload); + let readCount = 0; + const response = responseLike({ + body: { + getReader: () => ({ + read: async () => { + readCount += 1; + if (readCount === 1) return { done: false, value: undefined }; + if (readCount === 2) return { done: false, value: payload }; + return { done: true, value: undefined }; + }, + cancel: async () => undefined, + releaseLock: () => undefined, + }), + }, + }); + + const health = await verifyOrchestratorHealthz( + "https://orchestrator.example/healthz", + { + fetchImpl: async () => response, + }, + ); + + expect(health.service).toBe("contextual-orchestrator"); + }); + + it("preserves the oversized-body failure when stream cancellation also fails", async () => { + let releaseCalled = false; + const response = responseLike({ + body: { + getReader: () => ({ + read: async () => ({ + done: false, + value: new Uint8Array(65_537), + }), + cancel: async () => { + throw new Error("cancel failed"); + }, + releaseLock: () => { + releaseCalled = true; + }, + }), + }, + }); + + await expect(verifyOrchestratorHealthz( + "https://orchestrator.example/healthz", + { + fetchImpl: async () => response, + }, + )).rejects.toThrow(/too large/); + expect(releaseCalled).toBe(true); + }); + + it("preserves the advertised-size rejection when body cancellation rejects", async () => { + let cancelCalled = false; + const response = responseLike({ + contentLength: "65537", + body: { + cancel: async () => { + cancelCalled = true; + throw new Error("cancel failed"); + }, + }, + }); + + await expect(verifyOrchestratorHealthz( + "https://orchestrator.example/healthz", + { + fetchImpl: async () => response, + }, + )).rejects.toThrow(/too large/); + expect(cancelCalled).toBe(true); + }); +}); diff --git a/test/orchestrator-gateway-routing-alias.test.ts b/test/orchestrator-gateway-routing-alias.test.ts new file mode 100644 index 000000000..ae6c8a282 --- /dev/null +++ b/test/orchestrator-gateway-routing-alias.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { resolveOrchestratorModel } from "../scripts/lib/orchestrator-gateway.mjs"; +import { runVerifyOrchestratorGatewayCli } from "../scripts/verify-orchestrator-gateway.mjs"; + +describe("contextual-orchestrator routing alias authority", () => { + it("rejects a configurable model override before network access", async () => { + let fetchCalled = false; + const stdout: string[] = []; + const stderr: string[] = []; + + const exitCode = await runVerifyOrchestratorGatewayCli({ + argv: [], + env: { + NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_API_KEY: "gateway-token", + NOEMA_LLM_MODEL: "gpt-5", + }, + fetchImpl: async () => { + fetchCalled = true; + return new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ); + }, + writeStdout: (message) => stdout.push(message), + writeStderr: (message) => stderr.push(message), + }); + + expect(exitCode).toBe(1); + expect(fetchCalled).toBe(false); + expect(stdout.join("")).toBe(""); + expect(stderr.join("")).toMatch( + /NOEMA_LLM_MODEL must equal contextual-orchestrator/, + ); + }); + + it("rejects a non-canonical alias at the shared library boundary", () => { + expect(() => resolveOrchestratorModel("gpt-5")).toThrow( + /NOEMA_LLM_MODEL must equal contextual-orchestrator/, + ); + }); +}); diff --git a/test/orchestrator-gateway-secret-source.test.ts b/test/orchestrator-gateway-secret-source.test.ts new file mode 100644 index 000000000..3d2217959 --- /dev/null +++ b/test/orchestrator-gateway-secret-source.test.ts @@ -0,0 +1,113 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + readGatewayTransportValue, + verifyOrchestratorGatewayContract, +} from "../scripts/lib/orchestrator-gateway.mjs"; +import { + createVerifyOrchestratorGatewayProcessCli, + runVerifyOrchestratorGatewayCli, +} from "../scripts/verify-orchestrator-gateway.mjs"; + +function healthyResponse(): Response { + return new Response( + JSON.stringify({ status: "ok", service: "contextual-orchestrator" }), + { status: 200 }, + ); +} + +function envWithoutSecretAccess(): NodeJS.ProcessEnv { + const source: NodeJS.ProcessEnv = { + NOEMA_LLM_API_URL: "https://orchestrator.example/v1", + NOEMA_LLM_MODEL: "contextual-orchestrator", + NOEMA_LLM_API_KEY: "must-never-be-read-by-preflight", + }; + return new Proxy(source, { + get(target, property, receiver) { + if (property === "NOEMA_LLM_API_KEY") { + throw new Error("gateway preflight must not read the raw LLM secret environment variable"); + } + return Reflect.get(target, property, receiver); + }, + }); +} + +function workflowStep(workflow: string, name: string, nextName: string): string { + const start = workflow.indexOf(` - name: ${name}`); + const end = workflow.indexOf(` - name: ${nextName}`, start + 1); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return workflow.slice(start, end); +} + +describe("contextual-orchestrator secret-source policy", () => { + it("keeps the injectable preflight secret-free while validating gateway identity", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + const exitCode = await runVerifyOrchestratorGatewayCli({ + argv: [], + env: envWithoutSecretAccess(), + fetchImpl: async () => healthyResponse(), + writeStdout: (message) => stdout.push(message), + writeStderr: (message) => stderr.push(message), + }); + + expect(exitCode).toBe(0); + expect(stderr).toEqual([]); + expect(stdout.join("")) + .toContain("Verified contextual-orchestrator gateway identity."); + }); + + it("refuses secret names before reading the transport map", () => { + expect(() => readGatewayTransportValue( + envWithoutSecretAccess(), + "NOEMA_LLM_API_KEY", + )).toThrow(/non-secret gateway settings/); + }); + + it("keeps the reusable gateway verifier secret-free", async () => { + await expect(verifyOrchestratorGatewayContract({ + env: envWithoutSecretAccess(), + fetchImpl: async () => healthyResponse(), + })).resolves.toEqual({ + apiUrl: "https://orchestrator.example/v1", + model: "contextual-orchestrator", + healthzUrl: "https://orchestrator.example/healthz", + }); + }); + + it("filters the real process adapter down to non-secret preflight settings", async () => { + const cli = createVerifyOrchestratorGatewayProcessCli({ + argv: [process.execPath, "scripts/verify-orchestrator-gateway.mjs"], + env: envWithoutSecretAccess(), + fetchImpl: async () => healthyResponse(), + }); + + await expect(cli()).resolves.toBe(0); + }); + + it("materializes the inference secret only for the credential-consuming OpenCode step", () => { + const workflow = readFileSync( + ".github/workflows/hourly-product-development.yml", + "utf8", + ); + const preflight = workflowStep( + workflow, + "Verify contextual-orchestrator gateway and write OpenCode config", + "Install checksum-pinned OpenCode CLI", + ); + const openCode = workflowStep( + workflow, + "Run one contextual-orchestrator OpenCode session", + "Bound and export proposal without executing it", + ); + + expect(preflight).toContain("NOEMA_LLM_API_URL"); + expect(preflight).toContain("NOEMA_LLM_MODEL"); + expect(preflight).not.toContain("NOEMA_LLM_API_KEY"); + expect(openCode).toContain( + "NOEMA_LLM_API_KEY: ${{ secrets.NOEMA_LLM_API_KEY }}", + ); + }); +}); diff --git a/test/orchestrator-gateway-stream-bound.test.ts b/test/orchestrator-gateway-stream-bound.test.ts new file mode 100644 index 000000000..37fde5e64 --- /dev/null +++ b/test/orchestrator-gateway-stream-bound.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { verifyOrchestratorHealthz } from "../scripts/lib/orchestrator-gateway.mjs"; + +describe("contextual-orchestrator streamed health response", () => { + it("stops a chunked response at the byte ceiling without arrayBuffer materialization", async () => { + let readCount = 0; + let cancelled = false; + let released = false; + let arrayBufferCalled = false; + const reader = { + async read() { + readCount += 1; + if (readCount === 1) { + return { done: false, value: new Uint8Array(65_536) }; + } + if (readCount === 2) { + return { done: false, value: new Uint8Array(1) }; + } + throw new Error("reader continued after the configured byte ceiling"); + }, + async cancel() { + cancelled = true; + }, + releaseLock() { + released = true; + }, + }; + const response = { + ok: true, + status: 200, + headers: { get: () => null }, + body: { getReader: () => reader }, + async arrayBuffer() { + arrayBufferCalled = true; + throw new Error("unbounded arrayBuffer materialization"); + }, + } as unknown as Response; + + await expect( + verifyOrchestratorHealthz("https://orchestrator.example/healthz", { + fetchImpl: (async () => response) as typeof fetch, + }), + ).rejects.toThrow(/health response is too large/); + + expect(readCount).toBe(2); + expect(cancelled).toBe(true); + expect(released).toBe(true); + expect(arrayBufferCalled).toBe(false); + }); +}); diff --git a/test/orchestrator-gateway-trailing-dot-host.test.ts b/test/orchestrator-gateway-trailing-dot-host.test.ts new file mode 100644 index 000000000..c1fae80d3 --- /dev/null +++ b/test/orchestrator-gateway-trailing-dot-host.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { + directProviderHosts, + parseOrchestratorGatewayUrl, +} from "../scripts/lib/orchestrator-gateway.mjs"; + +describe("contextual-orchestrator direct-provider hostname canonicalization", () => { + it("rejects DNS-root-dot aliases of every forbidden direct provider host", () => { + for (const host of directProviderHosts()) { + expect(() => parseOrchestratorGatewayUrl(`https://${host}./v1`)).toThrow( + /contextual-orchestrator, not a direct model provider/, + ); + } + }); + + it.each(["https://./v1", "https://../v1"])( + "rejects a root-dot-only hostname before any health request: %s", + (url) => { + expect(() => parseOrchestratorGatewayUrl(url)).toThrow( + /absolute HTTPS URL/, + ); + }, + ); +}); diff --git a/test/package-manager-reproducibility.test.ts b/test/package-manager-reproducibility.test.ts index a3b4c4e11..24d291a67 100644 --- a/test/package-manager-reproducibility.test.ts +++ b/test/package-manager-reproducibility.test.ts @@ -166,19 +166,30 @@ describe("package-manager reproducibility contract", () => { expect(ciWorkflow).toContain('test "$(git rev-parse HEAD)" = "$NOEMA_EXPECTED_HEAD_SHA"'); }); - it("validates the pull-request base as exactly forty lowercase hexadecimal characters", () => { - const shaGate = ciWorkflow.indexOf('if [[ ! "$NOEMA_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then'); - const baseRead = ciWorkflow.indexOf('git show "${NOEMA_PR_BASE_SHA}:package-lock.json"'); + it("validates the fresh live pull-request base as exactly forty lowercase hexadecimal characters", () => { + const shaGate = ciWorkflow.indexOf('if [[ ! "$live_base_sha" =~ ^[0-9a-f]{40}$ ]]; then'); + const exportLiveBase = ciWorkflow.indexOf( + "printf 'NOEMA_LIVE_BASE_SHA=%s\\n' \"$live_base_sha\" >> \"$GITHUB_ENV\"", + ); + const lockfileGuard = ciWorkflow.indexOf( + 'if [[ ! "$NOEMA_LIVE_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then', + ); + const baseRead = ciWorkflow.indexOf( + 'git show "${NOEMA_LIVE_BASE_SHA}:package-lock.json"', + ); + expect(shaGate).toBeGreaterThan(-1); - expect(baseRead).toBeGreaterThan(shaGate); - expect(ciWorkflow).toContain("printf '::error::Invalid pull-request base SHA.\\n'"); - expect(ciWorkflow).toContain("exit 1"); + expect(exportLiveBase).toBeGreaterThan(shaGate); + expect(lockfileGuard).toBeGreaterThan(exportLiveBase); + expect(baseRead).toBeGreaterThan(lockfileGuard); + expect(ciWorkflow).toContain("printf '::error::Live pull-request base ref did not resolve to a full commit SHA.\\n'"); + expect(ciWorkflow).toContain("printf '::error::Invalid live pull-request base SHA.\\n'"); expect(ciWorkflow).not.toContain( "[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]", ); }); - it("refuses stale pull-request base evidence before lockfile validation and after verification", () => { + it("binds lockfile validation to one fresh live base and refuses base movement during verification", () => { const beforeGate = ciWorkflow.indexOf("name: verify live pull-request base before lockfile control"); const lockfileGate = ciWorkflow.indexOf("name: verify lockfile change control"); const releaseVerify = ciWorkflow.indexOf("name: release verify"); @@ -189,9 +200,18 @@ describe("package-manager reproducibility contract", () => { expect(releaseVerify).toBeGreaterThan(lockfileGate); expect(afterGate).toBeGreaterThan(releaseVerify); expect(ciWorkflow).toContain("NOEMA_PR_BASE_REF: ${{ github.event.pull_request.base.ref }}"); - expect(ciWorkflow).toContain("NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}"); + expect(ciWorkflow).toContain( + 'git merge-base --is-ancestor "$live_base_sha" "$NOEMA_EXPECTED_HEAD_SHA"', + ); + expect(ciWorkflow).toContain( + 'printf \'NOEMA_LIVE_BASE_SHA=%s\\n\' "$live_base_sha" >> "$GITHUB_ENV"', + ); expect(ciWorkflow.match(/gh api graphql/g)?.length).toBeGreaterThanOrEqual(2); expect(ciWorkflow.match(/ref\(qualifiedName:\$qualifiedName\)\{target\{oid\}\}/g)?.length).toBeGreaterThanOrEqual(2); - expect(ciWorkflow.match(/test \"\$live_base_sha\" = \"\$NOEMA_PR_BASE_SHA\"/g)?.length).toBeGreaterThanOrEqual(2); + expect(ciWorkflow).toContain('if [ "$live_base_sha" != "$NOEMA_LIVE_BASE_SHA" ]; then'); + expect(ciWorkflow).toContain('test "$live_base_sha" = "$NOEMA_LIVE_BASE_SHA"'); + expect(ciWorkflow).not.toContain( + "NOEMA_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}", + ); }); }); diff --git a/test/package-manager-review-contract.test.ts b/test/package-manager-review-contract.test.ts index fa73fb5f1..9782b0d78 100644 --- a/test/package-manager-review-contract.test.ts +++ b/test/package-manager-review-contract.test.ts @@ -103,22 +103,22 @@ describe("package-manager review contracts", () => { } }); - it("keeps the exact SHA guard inside the lockfile git-show step", () => { + it("keeps the exact live-base SHA guard inside the lockfile git-show step", () => { const step = workflowStep( ciWorkflow, "verify lockfile change control", "install", ); const guard = step.indexOf( - 'if [[ ! "$NOEMA_PR_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then', + 'if [[ ! "$NOEMA_LIVE_BASE_SHA" =~ ^[0-9a-f]{40}$ ]]; then', ); const error = step.indexOf( - "printf '::error::Invalid pull-request base SHA.\\n'", + "printf '::error::Invalid live pull-request base SHA.\\n'", guard, ); const exit = step.indexOf("exit 1", error); const baseRead = step.indexOf( - 'git show "${NOEMA_PR_BASE_SHA}:package-lock.json"', + 'git show "${NOEMA_LIVE_BASE_SHA}:package-lock.json"', exit, ); diff --git a/vitest.config.ts b/vitest.config.ts index ab5671104..a7f8bbca2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,8 @@ export default defineConfig({ "scripts/maintainer-app-readiness.mjs", "scripts/normalize-commercial-readiness-evidence.mjs", "scripts/prepare-agent-pr-message.mjs", + "scripts/verify-orchestrator-gateway.mjs", + "scripts/lib/orchestrator-gateway.mjs", "scripts/workflow-registry-audit.mjs", "scripts/workflow-registry-disable-plan.mjs", "scripts/workflow-registry-live-disable.mjs", @@ -19,6 +21,7 @@ export default defineConfig({ "scripts/lib/external-scheduler-evidence-audit.mjs", "scripts/lib/stable-file-evidence.mjs", "scripts/lib/strict-json-evidence.mjs", + "scripts/lib/acquisition-data-room-catalog.mjs", "scripts/lib/acquisition-data-room-integrity.mjs", "scripts/lib/acquisition-git-preflight.mjs", "scripts/lib/acquisition-private-output.mjs",