diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bb05cd81d..a28f0a6ee9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ on: - "tests/**" - "scripts/**" - "gui/**" + - "go/**" - "assets/**" - ".gitattributes" - ".npmignore" @@ -179,6 +180,7 @@ jobs: - 'tests/**' - 'scripts/**' - 'gui/**' + - 'go/**' - 'assets/**' - '.gitattributes' - '.npmignore' @@ -282,6 +284,16 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + # The Go sidecar parity test (tests/go-sidecar-parity.test.ts) builds the + # sidecar with the local toolchain; whichever shard picks the file up runs + # the oracle only when `go` is on PATH. Keep it installed so the oracle + # never silently skips in the ordinary suite. + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go/go.mod + cache: false + # The GUI install is NOT optional here, however unrelated it looks to a # test shard. Several files under tests/ import JSX-bearing modules from # gui/src (ProviderRail and friends), and React is declared only in @@ -380,6 +392,67 @@ jobs: - name: Test api usage API run: bun test --isolate ./tests/api-usage.test.ts + # Go sidecar line (ADR-0008): the fresh in-tree Go module plus the differential + # oracle that proves the TS health handler and the ocx-sidecar agree on status, + # headers, and the normalised body for GET /api/system/health. Runs the Go + # toolchain gates (build/vet/test under go/) and then the Bun parity harness + # against the binary it builds. The module has no external dependencies, so + # module caching is disabled. + go: + name: go + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + # The sidecar is built static per the ADR-0008 plan (CGO_ENABLED=0); the + # parity harness sets the same flag when it builds a throwaway binary. + CGO_ENABLED: "0" + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go/go.mod + cache: false + + - name: Go build + run: cd go && go build ./... + + - name: Go vet + run: cd go && go vet ./... + + - name: Go test + run: cd go && go test ./... + + - name: Cross-compile every release target (CGO_ENABLED=0) + # Ticket #8 acceptance: the ocx-sidecar must build static on every + # release target (linux/darwin/windows x amd64/arm64), not only the + # native platform of this runner. A platform-specific dependency that + # leaks cgo (or a build tag mistake) would otherwise surface only at + # release time. The loop proves all six combinations produce a binary; + # artifacts land in /tmp and are discarded. + run: | + set -euo pipefail + cd go + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do + os="${target%/*}"; arch="${target#*/}" + GOOS="$os" GOARCH="$arch" CGO_ENABLED=0 go build -o "/tmp/ocx-sidecar-$os-$arch" ./cmd/ocx-sidecar + done + + - name: Differential oracles + run: bun test --timeout 60000 tests/go-sidecar-parity.test.ts tests/go-cli-parity.test.ts tests/go-upgrade-rollback-drill.test.ts + # Everything that is not the suite: type safety, privacy, lint, build, smoke. # One runner, once per push. Splitting these across the shards would repeat a # fixed couple of minutes four times to save nothing. @@ -486,6 +559,14 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + # Same rationale as the Linux shards: this lane runs the whole suite, + # which includes the Go-sidecar differential oracle. + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go/go.mod + cache: false + - name: Install dependencies run: | bun install --frozen-lockfile @@ -498,6 +579,14 @@ jobs: cd gui bun run build + # Run the two data-plane differentials explicitly before the full-suite + # control. The unsharded suite also discovers them, but this named step + # makes macOS parity evidence independently visible and stable. + - name: Hot-path differential oracles + run: >- + bun test --timeout 60000 + tests/go-hotpath-relay.test.ts tests/go-hotpath-seam.test.ts + # Bun 1.3.14 segfaults while reclaiming a Worker at an `--isolate` file # boundary: the header shows BALANCED `workers_spawned(N) # workers_terminated(N)` and the process dies with exit 133 after the last @@ -623,6 +712,14 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun + - name: Setup Go + # The explicit Windows hot-path differential below builds and executes + # a native sidecar, so GOOS cross-compilation alone is insufficient. + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go/go.mod + cache: false + - name: Install dependencies run: | bun install --frozen-lockfile @@ -635,6 +732,14 @@ jobs: cd gui bun run build + # This is deliberately separate from the sharded suite: a shard can move + # with file ordering, while this named result is the Windows evidence for + # byte-identical relay and SSE-seam behaviour against a native sidecar. + - name: Hot-path differential oracles + run: >- + bun test --timeout 60000 + tests/go-hotpath-relay.test.ts tests/go-hotpath-seam.test.ts + - name: Test # --timeout: the Linux batches and the macOS control both pass 60000; this leg was # the only one left on Bun's 5s default, and it is the slowest hardware on the board. @@ -805,7 +910,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped` — which the gate is required to read as a deliberate skip. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, platform-windows, keyring-smoke, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, go, gates, platform-macos, platform-windows, keyring-smoke, npm-global-smoke] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/.github/workflows/go-release-artifacts.yml b/.github/workflows/go-release-artifacts.yml new file mode 100644 index 0000000000..157c602a0b --- /dev/null +++ b/.github/workflows/go-release-artifacts.yml @@ -0,0 +1,227 @@ +name: Go release artifact gate + +# Ticket #42: the Go binary is the release runtime (ADR-0008 increment 7), so +# the release path must build the same static cross-platform artifact CI has +# verified. #40 proved the artifact embeds every runtime asset (dashboard, CLI, +# identity); this workflow is that verification. It builds the exact release +# artifact via scripts/build-go-release-artifact.sh — the same script release.yml +# runs when it attaches ocx binaries to a release tag — and smokes the result. +# release.yml's own artifact build/attach steps (added with the #41 flip) remain +# the producer; this workflow is the gate that keeps the producer honest. +on: + # No base-branch filter on purpose (mirrors ci.yml): GitHub matches + # `branches:` against the BASE ref, which would silently exclude stacked child + # PRs. The `changes` job below is the real scope gate; a skipped job reports + # success, where a skipped *workflow* would leave its check pending forever. + pull_request: {} + # Pinned to the integration lines (mirrors ci.yml): the release path lives on + # main/preview, and dev is where the work queues before promotion. + push: + branches: [main, preview, dev] + paths: + # package.json is the version authority the artifact's -ldflags stamp + # reads, so a release commit (which bumps it) must re-run the gate even + # when no Go file changed — the same reason ci.yml's push allowlist + # carries package.json. + - "package.json" + - "go/**" + - "scripts/build-go-release-artifact.sh" + - "scripts/sync-go-embedded-dashboard.sh" + - ".github/workflows/go-release-artifacts.yml" + - ".github/workflows/release.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: go-release-artifacts-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Same paths-filter shape as ci.yml's `changes` job: on a pull request the + # filter decides whether the expensive verification jobs need to run, and the + # validation step fails the job (rather than producing a malformed output) + # when the filter misbehaves. + changes: + name: changes + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: read + outputs: + go: ${{ steps.scope.outputs.go }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Detect changed Go release surface + id: filter + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + with: + # Mirrors the push trigger's path allowlist above; keep the two in + # sync. On `pull_request` the action uses the PR's own file list; on a + # branch push it means "compare against the previous commit on this + # branch". + base: ${{ github.ref }} + filters: | + go: + - 'package.json' + - 'go/**' + - 'scripts/build-go-release-artifact.sh' + - 'scripts/sync-go-embedded-dashboard.sh' + - '.github/workflows/go-release-artifacts.yml' + - '.github/workflows/release.yml' + + - name: Assert the scope output is usable + id: scope + shell: bash + env: + GO_SCOPE: ${{ steps.filter.outputs.go }} + run: | + set -euo pipefail + case "$GO_SCOPE" in + true|false) + printf 'go=%s\n' "$GO_SCOPE" >> "$GITHUB_OUTPUT" + ;; + *) + printf '::error::changes.outputs.go was %q, expected true or false\n' "$GO_SCOPE" + exit 1 + ;; + esac + + verify-go-runtime: + name: verify Go runtime + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.go == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + # The release artifact is built static per ADR-0008; the build script sets + # the same flag, and this job keeps go build/vet/test consistent with it. + CGO_ENABLED: "0" + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go/go.mod + cache: false + + - name: Setup project Bun for embedded dashboard + uses: ./.github/actions/setup-project-bun + + - name: Build, vet, and test Go runtime + run: | + set -euo pipefail + cd go + go build -buildvcs=false ./... + go vet ./... + go test ./... + + - name: Build and smoke-test the Linux release artifact + # The release path stamps the package version with -ldflags, so the + # smoke runs the built candidate from a directory with no package.json: + # only a real stamp prints the exact version. The file check asserts + # the binary is genuinely static — CGO_ENABLED=0 plus a cgo-free + # platform surface is what makes the release artifact self-contained. + run: | + set -euo pipefail + scripts/build-go-release-artifact.sh linux/amd64 .tmp/go-release/linux-amd64 + candidate="$GITHUB_WORKSPACE/.tmp/go-release/linux-amd64/ocx-linux-amd64" + test -x "$candidate" || { echo "::error::release artifact is not executable: $candidate"; exit 1; } + file "$candidate" | tee /dev/stderr | grep -qE "ELF 64-bit.*statically linked" || { echo "::error::linux/amd64 artifact is not a static ELF binary"; exit 1; } + + expected="opencodex $(sed -n 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$GITHUB_WORKSPACE/package.json" | head -n 1)" + actual="$(cd /tmp && exec "$candidate" --version)" + if [ "$actual" != "$expected" ]; then + echo "::error::artifact --version printed ${actual@Q}, expected ${expected@Q} (ldflags stamp missing or stale)" + exit 1 + fi + echo "release artifact identity: $actual" + + build-release-artifact: + name: build ${{ matrix.target }} + needs: [changes, verify-go-runtime] + if: github.event_name != 'pull_request' || needs.changes.outputs.go == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - target: linux/amd64 + artifact: ocx-linux-amd64 + format: ELF 64-bit + arch: x86-64 + - target: linux/arm64 + artifact: ocx-linux-arm64 + format: ELF 64-bit + arch: aarch64 + - target: darwin/amd64 + artifact: ocx-darwin-amd64 + format: Mach-O + arch: x86_64 + - target: darwin/arm64 + artifact: ocx-darwin-arm64 + format: Mach-O + arch: arm64 + - target: windows/amd64 + artifact: ocx-windows-amd64 + format: PE32+ + arch: x86-64 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go/go.mod + cache: false + + - name: Setup project Bun for embedded dashboard + uses: ./.github/actions/setup-project-bun + + # Matrix values reach shell via env (repo convention: generated command + # input is a trust boundary — ci.yml passes TEST_SHARD the same way). + - name: Cross-compile static ocx release candidate + env: + TARGET: ${{ matrix.target }} + run: scripts/build-go-release-artifact.sh "$TARGET" dist + + - name: Verify the artifact format + # A platform-specific dependency that leaks cgo (or a build tag + # mistake) would surface here as a dynamic binary or a missing file, + # exactly where the release path would have shipped it. ELF rows also + # assert the static link; `file` describes Mach-O/PE32+ consistently + # enough that the format tag plus architecture is the reliable check. + env: + EXPECTED_FORMAT: ${{ matrix.format }} + EXPECTED_ARCH: ${{ matrix.arch }} + run: | + set -euo pipefail + description="$(file dist/ocx-*)" + echo "$description" + echo "$description" | grep -F "$EXPECTED_FORMAT" >/dev/null || { echo "::error::artifact format mismatch, wanted ${EXPECTED_FORMAT}"; exit 1; } + echo "$description" | grep -F "$EXPECTED_ARCH" >/dev/null || { echo "::error::artifact architecture mismatch, wanted ${EXPECTED_ARCH}"; exit 1; } + case "$EXPECTED_FORMAT" in + ELF*) echo "$description" | grep -F "statically linked" >/dev/null || { echo "::error::ELF artifact is not statically linked"; exit 1; } ;; + esac + + - name: Upload candidate + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ matrix.artifact }} + path: dist/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f053574295..50c4883944 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -244,6 +244,28 @@ jobs: echo "Cross-platform CI passed for ${GITHUB_SHA}: ${ci_url}" + # ADR-0008 increment 7 (#42): the Go binary is the release runtime, + # and the go-release-artifacts gate verifies the exact binaries this + # workflow attaches. Require its successful push run for this SHA the + # same way ci.yml is required above — a release must never attach + # artifacts CI has not proven. + go_gate_url="$( + gh run list \ + --workflow go-release-artifacts.yml \ + --branch "${GITHUB_REF#refs/heads/}" \ + --commit "$GITHUB_SHA" \ + --event push \ + --limit 10 \ + --json conclusion,url \ + --jq '[.[] | select(.conclusion == "success")][0].url // ""' + )" + if [ -z "$go_gate_url" ]; then + echo "::error::No successful Go release artifact gate run found for ${GITHUB_SHA} on ${GITHUB_REF#refs/heads/} (push event). Wait for the promotion run to pass before releasing." + gh run list --workflow go-release-artifacts.yml --commit "$GITHUB_SHA" --limit 10 || true + exit 1 + fi + echo "Go release artifact gate passed for ${GITHUB_SHA}: ${go_gate_url}" + # Service baseline (lineage-relative): merged tags only, so the # changed-files gate compares against the last release actually # reachable from this commit. The release-notes baseline below uses the @@ -387,6 +409,27 @@ jobs: npm view @bitkyc08/opencodex versions dist-tags --json || true exit 1 + # ADR-0008 increment 7: the Go binary is the release runtime. #41 flipped + # the runtime to Go and added these steps; #42 added the go-release-artifacts + # gate above that verifies every release target through the same script, so + # a release only attaches binaries CI has proven build static. The npm + # package remains the source distribution channel, but every release tag + # now ships a TypeScript-free single-binary artifact. + - name: Setup Go for single-binary artifacts + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go/go.mod + cache: false + + - name: Build Go single-binary release artifacts + run: | + set -euo pipefail + mkdir -p .tmp/go-release + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do + scripts/build-go-release-artifact.sh "$target" ".tmp/go-release" + done + ls -la .tmp/go-release + - name: Create GitHub release if: ${{ inputs.dry-run != true }} env: @@ -422,3 +465,15 @@ jobs: gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} + + - name: Attach Go artifacts to release + if: ${{ inputs.dry-run != true }} + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + release_tag="v${RELEASE_VERSION}" + for artifact in .tmp/go-release/ocx-*; do + gh release upload "$release_tag" "$artifact" --clobber + done diff --git a/.gitignore b/.gitignore index 19ab5348b8..8e42bfca59 100644 --- a/.gitignore +++ b/.gitignore @@ -59,9 +59,15 @@ devlog/**/security-advisory-draft* tests/.tmp-* .claude/ -# Retired Go native-runtime experiment. `go/` is not part of the build, the -# typecheck, or the test path, and nothing in `src/` imports it. A single file -# from it (go/internal/cli/config_parity.go) has now been committed by a broad -# `git add` three separate times and reached `dev` once — see -# tests/repo-hygiene.test.ts, which fails if any path here becomes tracked again. -go/ +# Go sidecar line (ADR-0008). `go/` is a tracked in-tree Go module under +# incremental takeover; only per-machine build output stays ignored so a stray +# `go build -o bin` cannot be committed. Nothing in `src/` imports `go/` at +# runtime — the TypeScript server spawns the built binary as a child process. +go/bin/ + +# Embedded dashboard build output (ADR-0008). go/internal/embeddedui/static/ +# carries only hand-written source assets; the release Vite build is read from +# gui/dist at runtime and must never be committed into the embed tree (the +# deterministic PR hygiene gate rejects generated build output, matching the +# repository-wide gui/dist convention). +go/internal/embeddedui/static/assets/ diff --git a/.pi/tasks/session-374649-374649/b8b6970ec.json b/.pi/tasks/session-374649-374649/b8b6970ec.json new file mode 100644 index 0000000000..a923ca6bd8 --- /dev/null +++ b/.pi/tasks/session-374649-374649/b8b6970ec.json @@ -0,0 +1,20 @@ +{ + "id": "b8b6970ec", + "name": "启动 ocx 代理测试", + "command": "cd /home/ubuntu/github/opencodex && OPENCODEX_HOME=/home/ubuntu/.opencodex bun run src/cli/index.ts start --port 10100 2>&1", + "description": "启动 opencodex 代理测试 tencent/glm-5.3-flash provider", + "status": "failed", + "outputPath": ".pi/tasks/session-374649-374649/b8b6970ec.output", + "cwd": "/home/ubuntu/github/opencodex", + "startTime": 1788847910996, + "endTime": 1788848762282, + "exitCode": 1, + "signal": null, + "pid": 1093868, + "bytesWritten": 745, + "isAgent": false, + "error": "Exited with code 1", + "notified": true, + "notifyOnCompletion": true, + "triggerOnCompletion": true +} diff --git a/.pi/tasks/session-374649-374649/b8b6970ec.output b/.pi/tasks/session-374649-374649/b8b6970ec.output new file mode 100644 index 0000000000..93e5225159 --- /dev/null +++ b/.pi/tasks/session-374649-374649/b8b6970ec.output @@ -0,0 +1,11 @@ +🚀 opencodex proxy running on http://localhost:10100 + POST /v1/responses → provider translation + POST /v1/chat/completions → OpenAI-compatible clients + GET /healthz → health check + GET /api/* → management API + GET / → GUI dashboard +[opencodex] Codex runtime: /home/[USER]/.bun/bin/codex (version=0.153.4, source=configured) + + 16 models appended to Codex catalog (/home/ubuntu/.codex/opencodex-catalog.json) + +🛑 Shutting down opencodex proxy... +⚠️ Native Codex restore failed during shutdown: EPERM: operation not permitted, rename '/home/ubuntu/.codex/config.toml.ocx.1093869.9.tmp' -> '/home/ubuntu/.codex/config.toml' Catalog restored to 11 native model(s) (dropped 16 proxy-routed). diff --git a/AGENTS.md b/AGENTS.md index 8d07b948ef..1cf18d753a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,10 @@ Bun-native TypeScript with no separate server compile step. `tests/helpers/`, broader scenarios in `tests/e2e-style/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. -- `go/` — retired Go native-runtime experiment; kept only where the TypeScript - runtime still references it. New work does not go here. +- `go/` — the Go runtime under incremental takeover (see + `docs/adr/0008-go-runtime-incremental-takeover.md`). The retired `dev2-go` + port is archived at `lidge-jun/opencodex-go-archive` (tag `archive/dev2-go`) + and is reference material only, not a fork. - `structure/` — maintainer invariants and architecture notes; read before changing shared subsystems. - `scripts/` — release and maintenance tooling; `scripts/release.ts` is the @@ -241,6 +243,20 @@ They are not regressions; do not re-investigate them: Everything else passes (15480 pass / 16 skip / 5 fail as of 2.35.0). +## Agent skills + +### Issue tracker + +Issues live in this repo's GitHub Issues; agent-created issues must use the `.github/ISSUE_TEMPLATE/` forms (the `enforce-issue-quality` gate closes freeform issues). See `docs/agents/issue-tracker.md`. + +### Triage labels + +Default five-label vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: root `CONTEXT.md` (when present) plus root `docs/adr/`. See `docs/agents/domain.md`. + ## Issues and pull requests (agents) Agent-created issues and PRs must use the repository templates. The gates @@ -282,9 +298,12 @@ than nudged. from `dev` (releases, docs deploys). Do not open feature PRs against `main`. - `preview` — prerelease train (`x.y.z-preview.*` versions). -Bun-native TypeScript on `dev` is the only runtime line. If native code -returns, the expectation is an incremental module (for example Rust via N-API) -landing on `dev`, not a second full-runtime branch. +Bun-native TypeScript on `dev` is the only runtime line today; it is being +migrated to Go as an incremental sidecar takeover, per +`docs/adr/0008-go-runtime-incremental-takeover.md`. The Go sidecar takes over +routes one at a time behind the TypeScript front door, ending in a single +static Go binary. The retired `dev2-go` parallel line stays retired — no +second full-runtime branch is being reopened. Stacked child pull requests that target another **open** PR's head branch are an intentional review workflow, not an alternate integration line. The diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000000..ab5a07b2de --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,75 @@ +# OpenCodex Go Takeover + +OpenCodex is migrating its CLI and server from Bun/TypeScript to a single static Go binary (ADR-0008), surface by surface, each proven by a differential oracle. This context covers the language of that migration: ownership, flips, the delegation seam, oracles, and the accepted end state. + +## Language + +### Ownership and the flip + +**Go-owned** / **TypeScript-owned**: +A command surface is owned by whichever implementation dispatches it. Go-owned surfaces live in `go/internal/ocxcli` and pass an oracle; TypeScript-owned surfaces delegate through the seam. The `Commands` table `Owner` field is the single source of truth. +_Avoid_: native (overloaded), migrated (implies a state reached without an oracle). + +**Surface**: +One executable face — a top-level command name, or a verb/sub-command inside a family. +_Avoid_: command (ambiguous between the surface and its implementation). + +**Family**: +A group of commands that migrate as one batch (e.g. launchers, lifecycle, management reads). +_Avoid_: group, area. + +**Flip**: +Moving one surface from TypeScript-owned to Go-owned, shipped as one batch with its oracle rows; the flip deletes the surface's deferral entry. +_Avoid_: port (a rewrite), migrate (whole-program drift). + +**Delegation seam**: +The mechanism that runs a TypeScript-owned surface from the Go binary (`DelegateToTypeScript`, `go/internal/ocxcli/delegate.go`). The ledger `deferredSurfaces` (`deferral.go`, issue #55) records every delegated surface with its reason and track. +_Avoid_: fallback (implies failure), shim (a different wrapper concept). + +**Deferral**: +An explicitly recorded TypeScript-owned surface in `deferredSurfaces` carrying a reason (why no oracle exists yet) and a track (the ticket or boundary record that lifts it). A deferral with no track is either platform-bound or awaiting an oracle. +_Avoid_: TODO, debt (both imply accident; deferrals are deliberate). + +### Oracles (ADR-0009) + +**Oracle**: +The evidence that the Go and TypeScript implementations are equivalent for a surface — the gate a flip must pass. Never replaced by one-sided tests. +_Avoid_: test, coverage (equivalence evidence, not pass/fail of one side). + +**T1 byte parity**: +Oracle by diffing stdout/stderr/exit code over identical argv against the same environment — the original and default form. +_Avoid_: "parity" alone (ambiguous with the masked form). + +**T2 masked parity**: +Oracle run in the same wall-clock window, with explicitly declared volatile fields masked — never an ad-hoc mask list. + +**T3 golden fixture**: +Oracle against offline stubs — a stubbed server, a fixed seeded home, a pinned catalog, a stubbed release endpoint. + +**T4 platform lane**: +Oracle per operating system (systemd / launchd / Windows), each lane its own environment. + +**T5 interactive subset**: +Oracle over only the non-interactive branches of an interactive command; the interactive branches stay on the Bun-dependent surface. + +**Volatile field**: +A field that legitimately differs between two equivalent runs and must be masked (T2) or declared non-deterministic. Declared where the oracle is, never silently. +_Avoid_: noise field (implies ignorable). + +### End state + +**Batch**: +One flip shipped as a unit — surface-map flip in cli.go + native dispatch + help + parity rows, in one commit (the #45–#54 pattern). +_Avoid_: PR-size, commit (batches are semantic units, not size buckets). + +**Standalone**: +The single Go binary that needs no Bun or checkout. Under ADR-0009 the meaning is precise: every oracle-able surface is Go-owned; the rest are an explicit Bun-dependent list. +_Avoid_: complete, done (both imply a state this repo defines precisely instead of assuming). + +**Static release identity**: +The immutable identity carried by a standalone artifact: its version and runtime source. It is defined at build time (or as the explicit `dev` identity for an unversioned development build) and cannot be overridden at runtime; it is never inferred from the working directory, a checkout, Bun, environment variables, or installation layout. The Go artifact ignores Bun runtime markers. `dev` is observable but is not comparable with release versions: a version-skew comparison with `dev` on either side never diagnoses a mismatch. Its runtime path names the resolved artifact, or `unknown` when the platform cannot resolve it. It varies by actual artifact without changing the shared status JSON shape or schema version. +_Avoid_: runtime detection, package discovery (both describe environment-dependent observations, not artifact identity). + +**Bun-dependent surface**: +A surface that deliberately keeps the TypeScript owner in the end state — interactive OAuth, OS service managers, Windows tray, network self-replace, coordinator transactions. Recorded per-surface; never a default category. +_Avoid_: leftovers, exceptions (both imply small or undesirable). diff --git a/MAINTAINERS.md b/MAINTAINERS.md index f7183db6ef..c96ba758fa 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -98,7 +98,8 @@ and ported under `go/`. That policy is withdrawn as of 2026-07-30. The dual-track cost outran its return: the carry backlog never cleared (17 commits and 9 open `needs-go-port` issues at the time of the decision, against 594 commits of divergence), and dogfooding the Go runtime kept producing new -defects. Bun-native TypeScript on `dev` is the single runtime line again. +defects. Bun-native TypeScript on `dev` was the single runtime line again — +until ADR-0008 reopened Go work as a sidecar takeover. - The branch has been deleted from this repository. Its full history is published at @@ -108,9 +109,12 @@ defects. Bun-native TypeScript on `dev` is the single runtime line again. issues (#661, #663, #666, #670, #674, #678, #680, #685, #703) were closed as not planned, and the `needs-go-port` label no longer exists on the repository. -- Future native work is expected to be an incremental module landing on `dev` - (Rust via N-API is the current candidate), not a second integration branch. - Reopening a parallel runtime line is an owner decision. +- Future native work is expected to be an incremental module landing on `dev`, + not a second integration branch. At the time of retirement the candidate was + Rust via N-API; ADR-0008 supersedes that with a Go runtime reopened as an + incremental sidecar takeover — a fresh `go/` codebase, not a fork of this + archive and not a parallel line. Reopening a parallel runtime line remains an + owner decision. See `docs/adr/0008-go-runtime-incremental-takeover.md`. ## Maintainer changes diff --git a/devlog/_fin/260909_ocx56_opencode_claude_flips/000_opencode_slice_design.md b/devlog/_fin/260909_ocx56_opencode_claude_flips/000_opencode_slice_design.md new file mode 100644 index 0000000000..e247dbcf91 --- /dev/null +++ b/devlog/_fin/260909_ocx56_opencode_claude_flips/000_opencode_slice_design.md @@ -0,0 +1,123 @@ +# `ocx opencode` flip to Go-owned — slice design (issue #56) + +Status: design confirmed by owner (grilling Q1–Q6, v2b shipped as `f339b24a3`). +Scope: flip the `opencode` WholeCommand to `GoOwned` with an env-capture shim +oracle. `update` and `claude` stay TS-owned for now (their deferral entries are +untouched by this slice). + +## What the TS owner does (src/cli/opencode.ts `cmdOpencode`) + +1. `loadConfig()`. +2. `ensureProxyForOpencode(config)`: `findLiveProxy()` first; when none, spawns a + detached `ocx start --port ` with `OCX_SERVICE=1` (+ + `OCX_API_TOKEN_FILE` hardening when no env token) and polls up to 8 s. + Failure prints `❌ Proxy did not become healthy after starting.` (exit 1). +3. `opencodeApiKey(config)`: env `OPENCODEX_API_AUTH_TOKEN` → service token file + → `config.apiKeys[0]?.key` → `"ocx"`. Never serialized into the inline config. +4. `fetchOpencodeProxyModels(live, apiKey)`: GET + `http://:/api/models` with `Accept: application/json` + (+ `X-OpenCodex-API-Key` when the trimmed token is non-empty), 8 s deadline; + error texts: timed-out / unreachable / non-2xx body `error` / unexpected payload. +5. `opencodeCatalogFromProxyRows`: drop disabled, drop native under Codex Direct, + first namespaced wins, drop fallback displayName. +6. `buildOpencodeProviderBlocksFromCatalog(port, catalog, hostname, config)`: + one V1 + one V2 block. **This is the same serializer `ocx export` uses** + (`opencodeProviderBlocks` in config-export.ts) — except export runs + `normalizeExportModels` (dedupe + sort) first and resolves baseURL as + `root + "/v1"`, while the launcher does NOT sort and resolves baseURL through + `opencodeProxyBaseUrl(port, hostname, config)` (probe-hostname form, plus the + `unauthenticatedLoopbackListener` standalone-codex target). Only the baseURL + resolution and the sort differ. +7. `opencodeProviderOverridePath(cwd)`: global + `$XDG_CONFIG_HOME|~/.config/opencode/opencode.json` then upward project + `opencode.json|.jsonc` search that stops at the git root; JSONC-parses each + file to detect `provider.opencodex` / `providers.opencodex`. +8. `buildOpencodeEnv(blocks, apiKey, process.env)`: + `OPENCODE_CONFIG_CONTENT` = JSON of `{...parsed, $schema: string?, + provider: {...existing.provider, opencodex: v1}, + providers: {...existing.providers, opencodex: v2}}`; child env additionally + gets `OPENCODE_API_KEY` (`OPENCODEX_OPENCODE_API_KEY`). +9. stderr wiring lines: + `✅ opencode wired to model(s) under provider \`opencodex\`.` + ` Your existing opencode config files are left untouched; only the runtime provider blocks are injected.` + `ℹ also defines our provider key; the runtime layer from ocx opencode overrides it for this launch.` (when override found) +10. spawn `opencode ` (win-exec commandInvocation), stdio inherit, + exit-code passthrough, ENOENT hint `❌ \`opencode\` CLI not found. Install it first: npm install -g opencode-ai`, + win32 9009 hint, other spawn errors`❌ Failed to launch opencode: `. + +## Reuse inventory on the Go side (go/internal/ocxcli) + +- `liveProxyEndpoint` / `proxyServesOpencodex` / `baseURL(state)` — already + byte-ported (usage/observe/export use them); oracle-green. +- `exportModelsFromProxyRowsRaw` + `opencodeCatalogFromProxyRows` + + `normalizeExportModels` + `exportOpenCodeProviderBlocks` + labels / context / + effort-variants (export_models.go, export_build.go) — byte-ported from the + same TS `opencodeProviderBlocks` serializer family; export parity is green. +- Config view reading (`codexDirect`, `unauthLoopback`, `hostname`) — + `readExportConfig` in export_command.go. +- Launcher spawn shell: `minimax_launcher.go` (spawnLauncherClient, + launcherCommandInvocation win-exec port, hint conventions) as pattern. + +New code needed (opencode_command.go + tests): + +- baseURL via `opencodeProxyBaseUrl` port (probe-hostname + standalone target); + the launcher's non-sorted, non-normalized catalog → blocks path reusing the + export serializer's per-model mapping (verify byte-identity with a probe + before trusting reuse — the model projection differs: catalog keeps provider + `""` vs export's "routed"/"openai" fallback). +- `opencodeApiKey` precedence (env → service token file → config key → "ocx"). +- fetchOpencodeProxyModels error texts (distinct from fetchManagementJSON). +- JSONC strip/parse + provider-override path search + git-root walk. +- merge/serialize of `OPENCODE_CONFIG_CONTENT`. +- spawn `opencode` + stdio inherit + exit/hint handling. + +## Oracle (env-capture shim) + +Describe-local Bun.serve fixture serving `/healthz` identity + `/api/models` +rows; runtime-port.json so both CLIs resolve the live proxy (no self-start). +PATH fake `opencode` shim (bash) that echoes `OPENCODEX_CONFIG_CONTENT`, +`OPENCODEX_OPENCODE_API_KEY`, and argv, then exits with a row-chosen code. +Rows compare TS vs Go bytes for stdout/stderr/exit: +normal catalog, empty/disabled/native-direct shapes, inherited valid +OPENCODE_CONFIG_CONTENT (foreign keys preserved), inherited invalid JSON / +non-object, project override detection, ENOENT (no shim), shim exit-code +passthrough, catalog fetch failure, and api-key precedence rows. win32 rows +skipped (no bash-shim oracle). Self-start path excluded from parity (spawns a +real proxy) — covered by Go unit tests with injected deps only. + +## Verification gate + +`go test ./...`, `go vet ./internal/ocxcli/`, `bun run typecheck`, focused +parity describe green; delete the opencode deferral entries (WholeCommand only — +opencode has no SubcommandSeam), flip cli.go table row + dispatch, update issue +# 56 Progress, one commit. + +## Outcome (2026-09-09) + +Landed as `flip the ocx opencode launcher to Go (issue #56)`: + +- Engine (`opencode_engine.go`) + command (`opencode_command.go`) + four Go test + files; goldens frozen from the real TS serializer at a fixed port; the fetch + lane has an injected-client harness for the timeout/unreachable/status/body + error texts. Full `go test ./...`, `go vet`, and `bun run typecheck` green. +- Parity describe "ocx opencode slice (issue #56)" adds 7 rows against a live + fixture proxy + env-capture shim: wired lane (pinned stderr + content bytes), + argv passthrough, exit-code passthrough, ENOENT hint, admission-key precedence + (service file > config apiKeys > placeholder), inherited-content merge + its + invalid-JSON error, and the provider-override ℹ line. All rows pass. +- cli.go row flips `opencode` → GoOwned with a dispatch case; the deferral.go + WholeCommand entry is deleted (ledger bijection green). Self-start lane and + win32 remain out of parity by design. + +### Residuals recorded + +- **apiKeys metadata repair notice**: the TS config loader emits + `⚠️ config.json apiKeys: repaired metadata …` on stderr when an apiKeys + entry lacks id/name/createdAt; the Go loader does not reproduce that + warning lane. Parity fixtures use metadata-complete entries so the row + compares launcher bytes only. Candidate for a config-family ticket in the + post-#56 inventory, not an opencode-engine defect. +- Detached-self-start env divergence (TS sets `OCX_SERVICE=1` + hardened + `OCX_API_TOKEN_FILE`; Go inherits the current env) — same service-token + bootstrap for a normal shell; documented in opencode_command.go, unexercisable + by parity (the oracle always has a live fixture proxy). diff --git a/devlog/_fin/260909_ocx56_opencode_claude_flips/001_claude_slice_design.md b/devlog/_fin/260909_ocx56_opencode_claude_flips/001_claude_slice_design.md new file mode 100644 index 0000000000..08597b1cbb --- /dev/null +++ b/devlog/_fin/260909_ocx56_opencode_claude_flips/001_claude_slice_design.md @@ -0,0 +1,120 @@ +# claude slice design (issue #56): flip `ocx claude` launcher to Go + +Unit: `devlog/_plan/260909_ocx56_opencode_claude_flips/`. Sibling of +`000_opencode_slice_design.md` (opencode, landed `cd97aadc9`). + +## Confirmed decisions (grill 2026-09-09, owner sign-off) + +1. **Slice boundary — one flip of the entire cmdClaude launch engine.** local + (standalone/disconnected) and connected routes are config-file driven, not + argv separable, so v2a/v2b-style argv slicing is not expressible; gateway + cache pre-write and agents-inject interleave with the launch flow. Single + slice = single commit: auth-detect/mode, buildClaudeEnv full env assembly, + both routes, context windows (management API fetch + connected catalog + decode), gateway model cache write, agent roster sync, spawn/hints, + exit-code passthrough. +2. **`ocx claude desktop …` and `ocx claude config …` stay TypeScript.** New + OwnershipFor gate returns TypeScriptOwned for those argv[1] values → + runDelegated; deferral ledger records two SubcommandSeam rows. Issue #56's + claude entry covers the launcher engine only (desktop-3p and integrations + families need their own oracles per ADR-0009). +3. **Env provenance: Go treats its env as trusted.** Go has no Bun dotenv + layer and no launch-proof channel, so an ambient `ANTHROPIC_*` export is a + genuine parent export — same UX as the real npm launcher (which captures + parent env pre-Bun). The TS *untrusted* strip (direct `bun + src/cli/index.ts`) never fires in Go. Parity rows neutralize ambient + `ANTHROPIC_*` on both sides so the provenance strip never fires; the + exported-env (S5) subscription row is Go-unit-only, recorded as a residual. +4. **Oracle shape — shim dump + fetchedAt normalization.** Fake `claude` shim + on PATH dumps sorted env + `gateway-models.json` + `~/.claude/agents` + contents to stdout; `fetchedAt` ms normalized before compare (PID + normalization precedent in the parity file). Fixture proxy serves + `/healthz`, `/api/claude-code`, and `/v1/models?ids=cli`. + +## TS reference surface (src/cli/claude.ts + src/claude/*) + +| Piece | File | Notes | +| --- | --- | --- | +| cmdClaude | claude.ts | flow: enabled gate → client-state → route → context windows → buildClaudeEnv → root-skip notice → gateway cache → agents-inject (local only) → spawn | +| buildClaudeEnv | claude.ts | pure env assembly (~202 lines), IO injected | +| auth-detect | claude/auth-detect.ts | S1 .claude.json oauthAccount, S2 .credentials.json exists, S3 macOS keychain (absent off-darwin; exit 44 = absent), S5 exported env post-strip; ownTokens exclusion | +| auth-mode | claude/auth-mode.ts | config.claudeCode.authMode manual proxy/subscription; auto-unknown → subscription (historical); ownTokens never in subscription markerMode | +| context-windows | claude/context-windows.ts | resolveAutoContext (env override + maxContextTokens inert pair), [1m] marking, buildClaudeContextWindows, effectiveModelEnv slots | +| gateway-cache | claude/gateway-cache.ts | writeGatewayModelCache {baseUrl, fetchedAt: Date.now(), models} 0600; /^(claude\|anthropic)/i usable filter; refresh via /v1/models?limit=1000&ids=cli, anthropic-version + x-opencodex-api-key, 3s abort | +| agents-inject | claude/agents-inject.ts | ocx-*.md roster, generated-by marker, settings.json picker model, sync writes/deletes owned only | +| alias | claude/alias.ts | claude-ocx-/claude-ocx2- prefixes, aliasForRoute/aliasForNative/resolveAlias; desktop3pAlias fallback (desktop-3p.ts) | +| client/state | src/client/state.ts | readClientConnectionState kinds; tokenFingerprint sha256 hex (service-secrets) | +| service-secrets | src/lib/service-secrets.ts | readServiceApiTokenState; loadServiceTokenFromFile already ported (opencode) | +| launcher-context | src/cli/launcher-context.ts | trusted-context strip logic; Go has no proof channel → not ported (decision 3) | +| dispatch | src/cli/dispatch.ts | claude desktop/config sub-channels stay TS (decision 2) | + +Byte-critical lanes (console.error / messages): + +- "Claude inbound is disabled (config.claudeCode.enabled=false — flip the Claude ON toggle in the GUI or edit config)." +- `Client state is ${kind}: ${reason}` (invalid/mismatched) +- "Claude is not selected for this remote hub connection." +- "Connected service token is missing." / "Connected service token ownership changed." +- "❌ Proxy did not become healthy after starting." +- "⚠ Claude 인증을 확인하지 못했습니다 — 구독 방식으로 진행합니다. GUI에서 인증 모드를 직접 지정하면 이 판단을 덮어쓸 수 있습니다." +- "⚠ 모델 컨텍스트 정보를 불러오지 못했습니다 — 1M 자동 표시는 이번 실행에서 생략됩니다." (fetch catch) +- "⚠ Gateway model cache could not be refreshed; the model picker may be stale." / `…: ${message}` +- "⚠ Claude agent definitions could not be synced; check ~/.claude/agents permissions." / `…: ${message}` +- CLAUDE_INSTALL_HINT "❌ `claude` CLI not found. Install it first: npm install -g @anthropic-ai/claude-code" +- "❌ Failed to launch claude: ${err.message}" (ENOENT error lane) +- root-skip notices (real-OS gate; unexercised in non-root parity) +- auto-unknown warning emitted once per launch + +## Oracle rows (tests/go-cli-parity.test.ts `describe("ocx claude slice (issue #56)")`) + +Fixture `startClaudeFixture(...)`: one Bun.serve per row family serving +attested /healthz, /api/claude-code {contextWindows}, /v1/models?ids=cli +anthropic-flavored rows (claude/anthropic prefixed so the usable filter +passes). HOME + CLAUDE_CONFIG_DIR redirected to scratch; planted .claude.json, +.credentials.json, settings.json, agents dir, codex catalog file +(connected), service-api-token file. Env neutralized of ANTHROPIC_* per +decision 3. Both sides run sequentially against the same fixture; shim rows +skipped win32. + +- local + proxy mode (clean home, config apiKeys admission → ownTokens) +- local + subscription (oauthAccount in .claude.json) +- manual proxy / manual subscription (config.claudeCode.authMode) +- auto-unknown Korean warning row (unreadable/missing-but-marker env) +- disabled gate row; invalid/mismatched client-state rows +- connected rows: catalog decode aliases + remote /v1/models + token + fingerprint present/missing/mismatched +- gateway cache shim dump (fetchedAt normalized) +- agents roster shim dump (settings.json model pin, roster defs) +- context window [1m] marking slot row (fixture window >= 1M for a slot target) +- ENOENT row, exit-code passthrough row +- maxContextTokens + DISABLE_COMPACT pair row; alwaysEnableEffort row + +Parity-excluded (Go unit tests with injected deps, per opencode precedent): +ensureProxy detached self-start lane; darwin keychain probe (absent branch +only on non-darwin; darwin branch mirrors TS security-call semantics); +root --dangerously-skip-permissions notice (getuid 0); win32 9009 hint +(win-shim rows skipped, hint logic unit-tested); S5 exported-env row +(decision 3 residual). + +## Go file set (go/internal/ocxcli/) + +- claude_command.go — runClaude(args, deps): gates, client-state, route, + context windows, spawn wiring, exit passthrough +- claude_engine.go — buildClaudeEnv port + auth detection + markerMode +- claude_context.go — context-windows port (resolveAutoContext, [1m], + effectiveModelEnv slots, catalog decode + aliases) +- claude_cache.go — gateway cache write + /v1/models ids=cli refresh +- claude_agents.go — roster build + sync (ocx-*.md owned-file contract) +- claude_alias.go — claudeCodeAlias/claudeCodeNativeAlias + resolveAlias + + desktop3p fallback +- claude_*_test.go + golden_test.go (engine goldens frozen from TS) +- cli.go: row GoOwned + dispatch case + OwnershipFor desktop/config seam +- deferral.go: delete WholeCommand claude, add 2 SubcommandSeam +- deferral_test.go bijection rows update + +## Residuals (recorded, post-#56 candidates) + +- S5 exported-env subscription row: parity-unexpressible (decision 3); Go + unit-tested only. +- fetchedAt ms: parity normalizes; Go cache engine tests use injected clock. +- CLI parity harness restores: `ocx claude desktop|config` rows still exercise + the TS seam (delegate) — byte-identical by construction. diff --git a/devlog/_plan/260905_go_sidecar_takeover/000_plan.md b/devlog/_plan/260905_go_sidecar_takeover/000_plan.md new file mode 100644 index 0000000000..1090895a60 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/000_plan.md @@ -0,0 +1,80 @@ +# Go sidecar takeover — first increment: fresh `go/` + one read-only route + +Date: 2026-09-05 +Status: implemented on `dev-go` (first increment landed per ADR-0008) +ADR: [`docs/adr/0008-go-runtime-incremental-takeover.md`](../../docs/adr/0008-go-runtime-incremental-takeover.md) + +## Delivery notes (dev-go) + +The open questions below were settled during implementation as follows: + +- **Nested `go/go.mod`** was used (module `github.com/lidge-jun/opencodex/go`), keeping the Go + tree self-contained under `go/`. +- **Supervision primitive**: `src/providers/openai-sidecar.ts` turned out to be credential + selection, not process supervision, so the implementer built the small supervisor in + `src/server/go-sidecar.ts` (spawn → ready-line handshake → register → child-exit + deregistration), reusing `direct-local-http.ts` for the forwarded request and + `optional-shutdown-hooks.ts` for teardown. +- Activation is env-gated (`OPENCODEX_GO_SIDECAR_BIN`), synchronous within the + `startServer` activation window, and default-OFF, so a default install is byte-identical to + a build without Go. +- The differential oracle lives in `tests/go-sidecar-parity.test.ts`; Go toolchain gates and + the oracle run in CI under the `go` job plus setup-go on the suite lanes. +- `.gitignore`/`tests/repo-hygiene.test.ts` were reconciled with ADR-0008: `go/` is tracked + source again, `go/bin/` build output stays ignored. + +## Objective + +Land the first Go increment on `dev` per ADR-0008: a fresh Go module under +`go/` building an `ocx-sidecar` binary, spawned and supervised by the +TypeScript server, serving exactly one read-only management route +(`GET /api/system/health`) with byte-identical HTTP semantics, plus the +differential oracle harness that proves it. + +This increment migrates nothing else: no proxy hot path, no CLI, no write +route. Its only job is to prove the seam — TS front door → Go sidecar → +differential oracle — with zero user-visible change. + +## Shape + +- Go module at `go/` (`go.mod`, module path `github.com/lidge-jun/opencodex/go`), + a fresh codebase. Nothing is copied from `archive/dev2-go`; that archive is + reference material only (consulted, never forked). +- Binary: `go/cmd/ocx-sidecar` → `ocx-sidecar`, built `CGO_ENABLED=0`. +- The TS server spawns the sidecar as a child process and supervises it, + following the existing sidecar pattern (`openai-sidecar.ts` is the model). + It forwards only `GET /api/system/health` to the sidecar over the local HTTP + channel (`direct-local-http.ts`); every other route stays in-process TS. +- The sidecar serves the same JSON shape as the TS handler: + `{ status, service, version, uptime, pid }`. `status`, `service`, and + `version` must equal the TS values; `uptime` and `pid` are the sidecar's own + process values. + +## Differential oracle harness + +- A Bun test boots the TS server with the sidecar attached, then issues + `GET /api/system/health` twice — once to the TS in-process handler and once + to the Go sidecar — and asserts byte-identical responses after normalising + the declared volatile fields (`pid`, `uptime`). +- The normalisation set is explicit and declared, never ad-hoc, so no later + route can silently widen what "equal" means. +- This is the divergence class that sank `dev2-go` (Go runtime numbers rendered + under JavaScript labels); the harness must fail on any such drift, not log it. + +## Accept criteria + +- `go build ./...`, `go vet ./...`, and `go test ./...` clean under `go/`. +- The differential harness passes in CI: TS handler and Go sidecar agree on + status, headers, and the normalised body for `GET /api/system/health`. +- `bun run typecheck` and the existing Bun suite stay green — no TS behaviour + change. +- No route other than `GET /api/system/health` is affected, and the Go-owned + route is declared in the management route registry so the forwarding seam is + visible to the existing registry reconciliation test. + +## Open questions for the implementer + +- Nested `go/go.mod` (module `.../opencodex/go`) versus a root `go.mod`; nested + is assumed here to keep the Go tree self-contained under `go/`. +- Which sidecar supervision primitive to reuse (the exact sidecar spawner to + copy), settled during implementation against `openai-sidecar.ts`. diff --git a/devlog/_plan/260905_go_sidecar_takeover/010_lab_migrate_vs_cut_decision.md b/devlog/_plan/260905_go_sidecar_takeover/010_lab_migrate_vs_cut_decision.md new file mode 100644 index 0000000000..cccb2e23aa --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/010_lab_migrate_vs_cut_decision.md @@ -0,0 +1,94 @@ +# 010 — Decision: migrate the Compatibility Lab, do not cut it + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Status: **decided — migrate** (owner, recorded on [ticket #9](https://github.com/waxiangzi/opencodex/issues/9)) +Parent spec: [#6 — Migrate the Compatibility Lab to Go (ADR-0008 increment 6)](https://github.com/waxiangzi/opencodex/issues/6) +ADR: [`docs/adr/0008-go-runtime-incremental-takeover.md`](../../../docs/adr/0008-go-runtime-incremental-takeover.md) + +## Decision + +The Compatibility Lab is **migrated to Go**, not cut. It remains the last surface to move +(ADR-0008 increment 6 per spec #6) and stays behind the same ownership seam, reproducing +its opt-in activation gate and provider slot with byte-identical behavior. Cutting is +recorded as an owner-rejected alternative: the Lab is **not** removed from the single +binary, and no discontinuation documentation will be written. + +Ticket #9 asked for an explicit migrate-or-cut decision with a cost-vs-value basis so the +choice is made on evidence rather than defaulted. The evidence is summarized below; the +owner weighed the same evidence and chose migrate. + +## Cost-vs-value basis + +### Cost (acknowledged, and why it does not decide the outcome) + +- `src/lab/` is 117 TypeScript files / ~21.4k LOC of production code, with ~14.5k LOC of + tests across 58 test files — the largest opt-in subsystem in the tree. +- It is not a thin route layer: SQLite-backed projection/ledger/event stores, a secure + artifact store, a signed community registry with origin/revocation handling, conformance + suites, a live sandbox runner with MCP loopback and credential leases, an automation + scheduler, and a query layer all sit behind the public routes. +- A Go port must reproduce the synchronous, gap-free activation guarantee and the + core-owned slot contract ([#19](https://github.com/waxiangzi/opencodex/issues/19)), + then prove byte-identical route behavior under the differential oracle + ([#33](https://github.com/waxiangzi/opencodex/issues/33)). +- Migrating is strictly more work than cutting. That alone was never the question: the + question is whether the Lab is a shipped capability worth keeping. + +### Value (why migrate wins) + +- **The Lab is a shipped, GUI-exposed capability, not an experiment.** The dashboard + exposes a routing control that requires compatibility evidence + (`routing.compatibility.enabled`, "require evidence"), a Compatibility Matrix view, a + Lab section, and i18n strings in several locales. Cutting the Lab would remove that + control and quietly change routing for every install whose profile is gated on + compatibility evidence. ADR-0008 states the single-binary endpoint cannot quietly drop + a documented opt-in surface; the same principle applies before the flip. +- **Routing depends on it.** The compatibility evidence provider feeds the synchronous + routing assembler (`routeModelInternal`) through the core-owned provider slot. A cut is + not a UI cleanup — it deletes an evidence source the policy path already consults for + gated profiles. +- **The seams make migration bounded.** The 2026-07 decoupling campaign + ([`devlog/_fin/260814_lab_core_decoupling/`](../../_fin/260814_lab_core_decoupling/)) + was expensive precisely because the Lab had leaked into the core import graph. Today the + activation gate, the passive-route linker, the provider slot, and the optional shutdown + hooks are first-class core seams, and the boundary is machine-enforced + (`tests/core-lab-boundary.test.ts`). Porting into those same seams is mechanical where + it was previously architectural. +- **Independent gating neutralizes the schedule risk.** Lab is increment 6, gated on its + own terms (spec #6): it cannot block the management read/write surfaces, the hot path, + the CLI, or the flip (#7), which only needs the Lab batch in a terminal state. +- **Cut still costs.** Cutting would require its own release-note and docs work + (spec #6 US6), a GUI/i18n removal sweep, and a deprecation window for existing users — + real work with a permanently lost capability at the end. + +## Scope for later Lab tickets + +This decision sets the direction for the Lab batch under spec #6: + +- [#19 — Lab activation gate + provider-slot in Go](https://github.com/waxiangzi/opencodex/issues/19): + build, not re-scope. Reproduce the opt-in activation gate and the provider-slot seam in + Go so the "one provider, no Lab" user still executes no Lab code. +- [#33 — Lab routes migration + differential](https://github.com/waxiangzi/opencodex/issues/33): + the migrate branch applies — Lab routes go Go-owned and differential-green. No cut + documentation is to be written. +- The TypeScript Lab remains the operating surface until its batch; no Lab code changes in + increments 1–5 and no user-visible change before increment 6. + +## Revisit + +The decision is recorded now so later Lab tickets have a stable reference, but it is not +permanent. Spec #6 requires the porting cost to be estimated against usage before the Lab +batch commits. If adoption evidence collected at that point shows the Lab is effectively +unused, the owner may reopen [ticket #9](https://github.com/waxiangzi/opencodex/issues/9) +and flip this record to cut — the flip (#7) depends only on the Lab batch reaching a +terminal state, so revisiting before the batch starts costs nothing. + +## References + +- ADR-0008 (go runtime incremental takeover) — Lab "migrates last", explicit cut candidate. +- Spec #6 (ADR-0008 increment 6) — Lab migrate/cut framing and independent gating. +- Tickets #9 (this decision), #19 (activation gate + provider slot in Go), #33 (routes + migration + differential). +- `devlog/_fin/260814_lab_core_decoupling/` — why the Lab is seam-gated today. +- `tests/core-lab-boundary.test.ts` — the machine-enforced core/Lab boundary. diff --git a/devlog/_plan/260905_go_sidecar_takeover/020_ownership_plumbing.md b/devlog/_plan/260905_go_sidecar_takeover/020_ownership_plumbing.md new file mode 100644 index 0000000000..76502450f1 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/020_ownership_plumbing.md @@ -0,0 +1,113 @@ +# 020 — Ticket #14 delivered: read/write ownership split + batch-migration plumbing + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Status: implemented on `dev-go` +Tickets: [#8](https://github.com/waxiangzi/opencodex/issues/8) (1.1) → +[#10](https://github.com/waxiangzi/opencodex/issues/10) (1.2) / +[#11](https://github.com/waxiangzi/opencodex/issues/11) (1.3) → +[#12](https://github.com/waxiangzi/opencodex/issues/12) (1.4) → +[#13](https://github.com/waxiangzi/opencodex/issues/13) (1.5) → +[#14](https://github.com/waxiangzi/opencodex/issues/14) (2.1) +Parent specs: [#1](https://github.com/waxiangzi/opencodex/issues/1) (increment 1), +[#2](https://github.com/waxiangzi/opencodex/issues/2) (increment 2) + +## What this run delivered + +The critical path was walked in dependency order. Increment 1 (#8 → #10 ∥ #11 → +#12 → #13) already sat on `dev-go` (commit `4b8715a30` + follow-ups); this run +audited each acceptance criterion against the tree, closed the two gaps that +were still machine-unproven, and then implemented ticket #14 — the first +increment-2 ticket, which generalises the one-route seam into the plumbing the +read-surface batches will use. + +### #8 (1.1) gap closed: cross-platform build is now a CI gate + +The Go job already ran `go build/vet/test`, but only on the runner's native +platform. #8's acceptance — "builds CGO_ENABLED=0 on every release target" — +needed a proof. The `go` job in `.github/workflows/ci.yml` now loops the six +release targets (`linux/darwin/windows` × `amd64/arm64`) with `CGO_ENABLED=0` +and fails if any combination does not produce a binary. All six build clean +today; a future cgo leak or build-tag mistake surfaces in CI, not at release. + +### #11 (1.3) gap closed: crash observability is now machine-checked + +#11's "crashes surface via health/status" had been argued in prose (warn log + +in-process fallback) but not proven. The differential oracle now kills the +sidecar child mid-run and asserts the full observable contract: the forwarder +deregisters (base URL goes null) and the next `GET /api/system/health` +answers from the in-process handler with the pid flipped back to the proxy's +own — an observable change on the exact route the sidecar was serving, with no +window where health goes unanswered. + +### #14 (2.1): typed read/write ownership + single registry-driven branch + +Ticket #14's three acceptance criteria, and how each is met: + +- **Write routes cannot be marked Go-owned by mistake.** `ManagementRoute` in + `src/server/management/route-registry.ts` is now a discriminated union: the + write arm (`mutates: true`) has no `go` marker and the read arm + (`mutates: false`) carries an optional `GoOwnedRouteDeclaration`. Writing + `go:` onto a write route is a compile error. A runtime re-check in + `tests/go-ownership-plumbing.test.ts` re-derives the marker set from + `MANAGEMENT_ROUTES` and compares it with the exported + `GO_OWNED_MANAGEMENT_ROUTES`, so a cast or array-level workaround cannot + drift. + +- **Migrating a read route is a marker flip, not dispatch edits.** The bespoke + health forwarder call is gone from `system-routes.ts`. Dispatch now has ONE + branch, at the head of `handleManagementAPI`, that looks the request up in + the declared Go-owned surface (`findGoOwnedManagementRoute`) and only then + asks the optional-subsystem slot (`go-sidecar-slot.ts`, generalized from a + health forwarder to a route forwarder) to relay it. The Go-owned surface is + derived DATA (`GO_OWNED_MANAGEMENT_ROUTES`); the branch names no route, and a + test pins that `management-api.ts` contains no management-path literal in the + forwarding path. The next read route migrates by flipping its marker plus a + Go handler plus oracle coverage — no second dispatch edit exists to write. + +- **Per-route volatile declarations supported.** Each Go-owned route declares + its volatile fields in the registry (`health` declares `["pid","uptime"]`). + The differential oracle consumes the declaration instead of a mirrored + constant in `go-sidecar.ts`, so the oracle normalises exactly the declared + set and a later route cannot silently widen what parity means. The harness + also pins that the declared Go-owned surface is exactly health today, so an + accidental flip fails loudly. + +Fallback semantics are unchanged end to end: forwarder absent (default +install), returning `null`, or throwing → the in-process handler answers, +byte-identically to a build without Go. The in-process handler remains the +differential oracle. + +## Files + +- `src/server/management/route-registry.ts` — typed read/write ownership, + `GO_OWNED_MANAGEMENT_ROUTES`, `findGoOwnedManagementRoute`. +- `src/server/go-sidecar-slot.ts` — generalized core-owned forwarder slot. +- `src/server/go-sidecar.ts` — supervisor registers the generic forwarder. +- `src/server/management-api.ts` — the single forwarding branch. +- `src/server/management/system-routes.ts` — health back to pure in-process + fallback/oracle. +- `tests/go-ownership-plumbing.test.ts` — new: registry invariants + dispatch + behaviour with a fake forwarder (no Go toolchain needed). +- `tests/go-sidecar-parity.test.ts` — registry-driven normalisation; crash + fallback oracle (#11). +- `.github/workflows/ci.yml` — cross-platform build gate (#8). +- `go/README.md` — ownership marker contract. + +## Verification + +- `bun run typecheck` green. +- `tests/go-ownership-plumbing.test.ts` (9 pass), `tests/go-sidecar-parity.test.ts` + (4 pass, incl. crash fallback), `management-route-registry`, `core-lab-boundary`, + `ci-workflows`, `repo-hygiene`, `cli-capabilities`, `route-explainability`, + `skill-ocx` all green. +- `go build ./...` / `go vet ./...` / `go test ./...` green under `go/`; + all six release-target cross-compiles succeed with `CGO_ENABLED=0`. + +## Next on the path + +Spec #2 (increment 2) migrates the read-only management surface in batches: +system memory, models, providers, usage, quotas, config, catalog. Each batch +route flips its marker in the registry, gains a Go handler in +`go/cmd/ocx-sidecar`, and joins the registry-driven oracle. The plumbing to do +that without re-proving the seam is what ticket #14 established. diff --git a/devlog/_plan/260905_go_sidecar_takeover/030_read_surface_state_source_gate.md b/devlog/_plan/260905_go_sidecar_takeover/030_read_surface_state_source_gate.md new file mode 100644 index 0000000000..539bae10c2 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/030_read_surface_state_source_gate.md @@ -0,0 +1,157 @@ +# 030 — Frontier closes #8–#14 and the read-surface state-source gate + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Status: recorded on `dev-go`; issues #8–#14 closed as delivered +Tickets: closes [#8](https://github.com/waxiangzi/opencodex/issues/8) (1.1) → +[#10](https://github.com/waxiangzi/opencodex/issues/10) (1.2) / +[#11](https://github.com/waxiangzi/opencodex/issues/11) (1.3) → +[#12](https://github.com/waxiangzi/opencodex/issues/12) (1.4) → +[#13](https://github.com/waxiangzi/opencodex/issues/13) (1.5) → +[#14](https://github.com/waxiangzi/opencodex/issues/14) (2.1), plus +[#9](https://github.com/waxiangzi/opencodex/issues/9) (6.1) — in dependency order +Parent specs: [#1](https://github.com/waxiangzi/opencodex/issues/1) (increment 1), +[#2](https://github.com/waxiangzi/opencodex/issues/2) (increment 2 — see the gate below) + +## What this run delivered + +The first seven tickets were code-complete on `dev-go` (commits `4b8715a30` … +`148a51c6d`, recorded in `000_plan.md`, `010_lab_migrate_vs_cut_decision.md`, and +`020_ownership_plumbing.md`) but had never been **resolved** in the issue +tracker, so the frontier query kept returning them as available work. This run +re-ran every machine gate that backs them — `go build ./...` / `go vet ./...` / +`go test ./...` under `go/`, the six-target `CGO_ENABLED=0` cross-compile matrix, +the differential oracle, the ownership-plumbing suite, the route-registry +reconciliation, and `bun run typecheck` — and closed issues #8–#14 in dependency +order, each with a comment citing the tree evidence and this doc. + +Closing them exposes the true frontier: #15, #16, #17, #18, and #19 are now all +unblocked (each lists only #14 and/or #9 as a blocker). Auditing those tickets +against the actual handlers found a gate the batch texts do not state: + +> **A management route can be Go-served byte-identically only when its body is a +> pure function of state the sidecar process can see** — the environment it was +> spawned with, on-disk files, the OS, or its own process. Routes whose bodies +> report the *TypeScript process's* live state (in-memory maps and caches, +> `bun:jsc` introspection, module-level counters, memoized discovery) cannot be +> reproduced by a separate sidecar process before the flip, no matter how +> faithfully their handlers are ported. + +The health route migrated in #14 precisely because it sits at the portable end +of that spectrum (env + the sidecar's own process, two declared volatile +fields). Several routes in the next batches sit at the opposite end. + +## State-source classification + +Each candidate read route should be classified by where its body comes from +before batch work starts: + +- **env/on-disk (portable today)** — config-file content merged with the + version the supervisor passed at spawn. Both processes can read it; this is + the class the ownership marker was built for. +- **OS-level (portable with care)** — process enumeration, platform probes. Go + can reproduce these, but the matching semantics must be proven byte-exact + against the live TS oracle. +- **TS-process live state (NOT portable pre-flip)** — in-memory session and + replay tables, runtime introspection, module counters, memoized discovery, + drain/lifecycle state. A sidecar physically cannot return these values. + +### Ticket #15 (system reads) — per-route verdicts + +| route | handler | source | Go-servable pre-flip? | +|---|---|---|---| +| `GET /api/system/health` | `system-routes.ts` (Go-owned since #14) | env + own process | yes — the #14 precedent | +| `GET /api/system/memory` | `system-routes.ts` | TS-process live: `process.memoryUsage`, `bun:jsc` heapStats, `responseStateMetrics`, memory-watchdog snapshot, relay inspection counters, active-turn/drain counters | no | +| `GET /api/system/windows-replace-retries` | `src/lib/windows-atomic-replace.ts` (`counters` module map) | TS-process live | no | +| `GET /api/system/codex-app-server` | `src/codex/app-server-restart-service.ts` `readCodexAppServerState` | OS process catalog | yes in principle; catalog-matching parity must be proven | + +So #15 as written — "system read routes are Go-owned and byte-identical" — +cannot be closed while the TS server owns the process state: two of its three +unmigrated routes report TS-process introspection with no on-disk counterpart. +Only `codex-app-server` is plausibly portable, and one route does not close a +three-route ticket. + +### Ticket #16 (config reads) — mixed, and the pure core is the prize + +The config-core read bodies are disk-derived, but the GET handlers mix in live +process work: + +- `GET /api/settings` returns a disk-derived core (`port`, `hostname`, + `streamMode`, memory budget, toggles) **plus** `codexRuntime`, resolved by + `resolveCodexRuntime()` — memoized in-process discovery that locates and + versions the installed Codex binaries (`config-routes.ts`) — **plus** cached + `startupHealth` and the serving process's browser `timeZone`. +- `GET /api/startup-health` serves a cache invalidated by install actions. +- `GET /api/diagnostics/project-config` serves `getCachedProjectConfigDiagnostics` + — a scan cache populated by process work. +- `GET /api/update/check` / `GET /api/update/status` consult the update job + module (`src/update/job.ts`), which is process state. +- `GET /api/sidecar-settings` mixes config with candidate-row/model resolution. + +The valuable and genuinely portable artifact here is the **disk-derived core** — +one shared Go config-parsing implementation that turns the same on-disk config +into the same DTO bytes (#16's second acceptance criterion, and the dependency +#20/#21/#24/#35 all list). Route-level parity for the live fields needs a +per-field owner decision in the style of health's `volatileFields`: declare the +field volatile, forward a parent snapshot, or defer the whole route to the flip. + +### Ticket #18 (auth/session) — the session half is not portable + +- **Admin token**: file/env → portable; Go can read the same + `OPENCODEX_HOME/admin-api-token` or `OPENCODEX_ADMIN_AUTH_TOKEN`. +- **Dashboard session**: `ManagementAuthState.sessions` is an **in-memory + `Map`** in the TS process (`src/server/management-auth.ts`, + `src/server/gui-session.ts`). Sessions are opaque tokens into that map — there + is no stateless signed cookie a second process could verify. +- **Local capability principals**: HMACs over the process pid/port/attestation + secret, plus **in-process replay tables** (`consumedLocalReadCapabilities`, + the `admitted*Requests` weak sets) that live and die with the TS process. + +So #18's "Go validates the dashboard session; under-privileged requests are +rejected identically" cannot be proven while the session table and replay caches +exist only in the TS process. What is missing is a **principal-relay contract**: +the front door already admits the request; for Go to re-validate rather than +trust the hop blindly (spec #3), the front door must hand the sidecar an +assertion of the admitted principal that Go can verify against a shared secret. +That contract has no ticket yet and no consumer until a mutating route actually +migrates — it should be specified as part of the write-surface work, not before. + +### Ticket #19 (Lab gate) — premature + +The Go codebase contains no Lab subsystem yet, so a Go activation gate would +gate nothing until the Lab routes port (#33). #9's migrate-vs-cut decision is +recorded as **migrate** (`010_lab_migrate_vs_cut_decision.md`); the natural +reading is that the Go gate + provider-slot seam are built *as part of* the Lab +increment so the seam has something to activate. #19's acceptance wording ("an +opt-in activation gate exists in Go") is satisfiable only vacuously today. + +## Consequence and recommended order + +The next batch implementer should not take "every read route Go-owned and +byte-identical" as a literal instruction: several routes report process state +that a sidecar cannot know. Concretely: + +1. **Port #16's pure config-core first.** A shared Go config-parsing package is + the load-bearing artifact the rest of the program lists as a dependency. + Prove it byte-identical on the disk-derived subset of config read bodies and + resolve each live field (`codexRuntime`, `startupHealth`, updater state) by + one of the three per-field options above, recorded where the health route's + `volatileFields` live. +2. **Before #15, get an owner decision on the process-derived system routes.** + `memory` and `windows-replace-retries` cannot be Go-served pre-flip; the + registry's `exempt` mechanism already models a documented deferral, and a + defer-to-flip exemption is more honest than a sidecar inventing values. +3. **Specify the principal-relay contract before #18.** It becomes real work + only when a mutating route is being ported. +4. **Fold #19 into the Lab increment (#33)** so the gate has something to gate. + +## Verification + +- `go build ./...`, `go vet ./...`, `go test ./...` green under `go/`. +- Six-target cross-compile matrix (`linux/darwin/windows` × `amd64/arm64`, + `CGO_ENABLED=0`) produces all six binaries (#8 acceptance). +- `bun test tests/go-sidecar-parity.test.ts`: 4 pass — byte parity with the + declared normalisation, missing-binary no-op, crash fallback (#10/#11/#13). +- `bun test tests/go-ownership-plumbing.test.ts tests/management-route-registry.test.ts + tests/ci-workflows.test.ts tests/repo-hygiene.test.ts`: 170 pass (#12/#13/#14). +- `bun run typecheck` green. diff --git a/devlog/_plan/260905_go_sidecar_takeover/031_config_read_first_slice.md b/devlog/_plan/260905_go_sidecar_takeover/031_config_read_first_slice.md new file mode 100644 index 0000000000..ffe08a76c5 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/031_config_read_first_slice.md @@ -0,0 +1,92 @@ +# 031 — Ticket #16 first vertical slice: shared Go config parsing + strict shadow-call-settings parity + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Status: recorded on `dev-go`; ticket #16 OPEN (1 of 9 config read routes migrated) +Tickets: [#16](https://github.com/waxiangzi/opencodex/issues/16) (config read routes batch, spec #2) +Parent spec: [#2](https://github.com/waxiangzi/opencodex/issues/2) (increment 2) +Owner decision (2026-09-06): implement #16 next, per the ordering recommended in +`030_read_surface_state_source_gate.md` — port the pure config core first and +resolve live-field routes per-field. + +## What this run delivered + +The first vertical slice of #16, proven end to end through the differential +oracle: a shared Go config reader, one config read route served byte-identically +from it, and the registry/oracle semantics that make "this route has no volatile +field" a real, checkable contract. + +- **`go/internal/config`** — the shared Go config parser (the artifact #20/#21/ + #24/#35 all list as a dependency). It reads the operator's `config.json` from + the same place the TypeScript runtime reads it (`OPENCODEX_HOME`, defaulting + to `~/.opencodex`), decodes numbers with `json.Number` so a value echoed into + a response keeps its on-disk literal, and never rewrites or moves the file. It + mirrors only the TS-side normalisation the Go-owned route bodies depend on, + and keeps the raw decoded document (`Raw`) so later routes can project from + it without a full schema port. +- **`GET /api/shadow-call-settings` is now Go-owned** — a marker flip in + `route-registry.ts` plus a Go handler in `go/internal/sidecar` that projects + the `shadowCallIntercept` section through the exact TS rules + (`sci.enabled === true`, `sci.model ?? ""`, `shadowSourceModels` trim / + non-empty / default filtering, default `["gpt-5.6-luna"]`). The in-process TS + handler remains the fallback and the differential oracle. +- **Strict-byte ownership semantics.** The `go.volatileFields` marker may now be + EMPTY: that declares a route whose body is a pure function of shared state + and must be byte-identical with NO normalisation — the strongest contract the + oracle can impose, not a vacuous one (the raw wire bodies are compared). The + previous "must be non-empty" rule had it backwards: it only ever made sense + for routes that legitimately report the serving process's own values. The + interface doc, `go-ownership-plumbing.test.ts`, the parity oracle, and + `go/README.md` all state the new contract. + +## Why this route first + +Of #16's nine config read routes, this is the only one whose body is a pure +function of the on-disk config with no live-process dependency — the smallest +byte-parity target that exercises the whole seam (config file → Go parser → +Go handler → registry flip → oracle row). Empirical check first: a probe +through `saveConfig`/`loadConfig` confirmed `shadowCallIntercept` round-trips +unnormalised (values verbatim, empty strings kept), so TS in-memory == file +content for this section and byte parity is well-defined. + +## Verification + +- `go build ./...`, `go vet ./...`, `go test ./...` green under `go/` + (new `internal/config` package: 8 tests; `internal/sidecar`: 10 tests incl. + shape/order, defaults, coercions, narrow surface). +- `bun test tests/go-sidecar-parity.test.ts`: 6 pass — health volatile parity, + missing-binary no-op, crash fallback, PLUS two new strict cases: + shadow-call-settings default body and configured body are byte-identical + with no normalisation, and the front door relays the Go bytes unaltered. +- `bun test tests/go-ownership-plumbing.test.ts tests/management-route-registry.test.ts + tests/repo-hygiene.test.ts tests/ci-workflows.test.ts tests/cli-capabilities.test.ts + tests/route-explainability.test.ts`: 199 pass. +- `bun run typecheck` green. + +## Per-route status of #16 (config read routes, 1 of 9 migrated) + +| route | body source | status | +|---|---|---| +| `/api/shadow-call-settings` | pure config | **Go-owned, strict parity** | +| `/api/config` | pure fn of config, but a LARGE projection | next sub-increment: provider redaction policy (`providerEditorConfigDTO`), registry notes, xai opt-in, cost-overlay sanitisation, service-tier projection, key order | +| `/api/settings` | disk core + live fields | mixed: `codexRuntime` (memoized discovery), cached `startupHealth`, process `timeZone` — port the disk core; live fields need the per-field owner decision | +| `/api/sidecar-settings` | config core + candidate/model helpers | classify the helpers when attempted | +| `/api/startup-health` | in-process cache (install/repair actions) | defer — no on-disk counterpart | +| `/api/diagnostics/project-config` | in-process scan cache | defer — no on-disk counterpart | +| `/api/update/check` / `/api/update/status` | updater job module state | defer | +| `/api/windows-tray` | platform probe / static platform string | OS-level; low value; defer | + +The `/api/settings` and `/api/config` disk-derived cores are the two large +sub-increments left before #16's write of shared parsing is exercised broadly; +the cache/process routes stay TypeScript-owned until the flip, consistent with +`030_read_surface_state_source_gate.md`. Ticket #16 remains open. + +## Files + +- `go/internal/config/config.go`, `config_test.go` — new shared config reader. +- `go/internal/sidecar/sidecar.go`, `sidecar_test.go` — new route + shared + `respondJSON`; unit tests. +- `go/cmd/ocx-sidecar/main.go`, `go/README.md` — doc updates. +- `src/server/management/route-registry.ts` — strict-volatile semantics + marker. +- `tests/go-ownership-plumbing.test.ts`, `tests/go-sidecar-parity.test.ts` — + two-route surface pins + strict-parity oracle cases. diff --git a/devlog/_plan/260905_go_sidecar_takeover/032_read_batches_decision_record.md b/devlog/_plan/260905_go_sidecar_takeover/032_read_batches_decision_record.md new file mode 100644 index 0000000000..2e95895dd5 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/032_read_batches_decision_record.md @@ -0,0 +1,110 @@ +# 032 — Tickets #15/#16/#17 per-route decision record (read-surface batches, pre-flip scope) + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Status: recorded on `dev-go`; #16 and #17 advanced by one strict route each; #15 triaged to completion +Tickets: +- [#15](https://github.com/waxiangzi/opencodex/issues/15) (system read routes batch, spec #2) +- [#16](https://github.com/waxiangzi/opencodex/issues/16) (config read routes batch, spec #2) +- [#17](https://github.com/waxiangzi/opencodex/issues/17) (model/provider/catalog read views batch, spec #2) +Parent spec: [#2](https://github.com/waxiangzi/opencodex/issues/2) (increment 2) +Gate: `030_read_surface_state_source_gate.md` — a route is pre-flip +Go-servable byte-identically only when its body is a pure function of state the +sidecar can see (env / on-disk / OS / its own process). Everything else defers +to the flip, when the Go binary IS the serving process and legitimately owns +process state. + +Owner decision (2026-09-05/06): implement #15/#16/#17 under that framework; +deferral decisions are made and recorded here rather than re-escalated. + +## Why most of these routes cannot move pre-flip + +The in-process TS handler bodies reflect the SERVING proxy's live state: +discovery caches, update jobs, effort clamps, runtime probing, Windows tray +actions, project-config scan caches. A sidecar is a separate process; it cannot +see another process's memory, and inventing a snapshot would re-create the +dev2-go divergence class (Go values rendered under TS labels). The batches' +tickets were written when the takeover was imagined as mechanical route +copying; the state-source gate is the discovery that it is not. Migrating the +pure residue now and recording the deferrals is the owner-approved pre-flip +completion of each batch. Deferred routes migrate with the binary at the flip +(themselves Go-served, so process state becomes legitimate) under #25/#40/#41. + +## Migrated in this run (machine-proven via the differential oracle) + +- **`GET /api/custom-models` → Go-owned, STRICT** (ticket #17). The TS body is + `JSON.stringify(config.customModels ?? [])` — a raw echo of a zod-passthrough + config subsection (probed: unknown keys, per-entry key order and non-schema + values all survive a save/load round trip). Byte parity therefore needs + document-order JSON, not a typed projection. Added an ordered decoder + + JSON.stringify-compatible marshaler to the shared Go config package + (`go/internal/config/ordered.go`): object keys in file order, compact + whitespace, no HTML or U+2028/U+2029 escaping, the five control-char + shortcuts, lowercase `\u00xx` below U+0020 — all pinned against Bun. This is + also the foundation the `/api/config` provider-DTO port will need (provider + entries keep their file order). Marker: strict (`volatileFields: []`), the + oracle compares raw bytes. +- (Earlier slices already closed in: #14 delivered `/api/system/health`; + devlog 031 delivered `GET /api/shadow-call-settings` for #16.) + +## Per-route verdicts + +### #15 — system reads (`src/server/management/system-routes.ts`) + +| Route | Verdict | Reason (citation) | +|---|---|---| +| `GET /api/system/health` | **Go-owned** (#14, volatile pid/uptime) | own process values | +| `GET /api/system/memory` | defer to flip | body mixes OS memory with TS-runtime-owned keys: `bunVersion`, `jscHeap`, `responseState` etc. — the serving process's runtime internals, not reproducible by a sidecar. Flip: Go owns the process, keys become its own. | +| `GET /api/system/windows-replace-retries` | defer to flip | process-local retry counter (windows binary replacement state machine). | +| `GET /api/codex-app-server` | defer to flip | reports on ~1260 lines of app-server process management (`src/codex/app-server-processes.ts`): OS process enumeration + cached state + platform heuristics. Reimplementing the machinery for byte parity pre-flip is flip-scale work, not batch work. | + +### #16 — config reads (`src/server/management/config-routes.ts`) + +| Route | Verdict | Reason (citation) | +|---|---|---| +| `GET /api/shadow-call-settings` | **Go-owned, strict** (devlog 031) | pure function of config subsection | +| `GET /api/config` | defer (registry data) | body = `withProviderServiceTierDTO(safeConfigDTO(config))`; the DTO projects per-provider registry notes, `codexAccountMode`, service-tier records, redaction policy and xai opt-in state from the TS provider registry's static per-provider data. A partial port would be silently wrong for real providers (openai, anthropic, …) — the exact dev2-go defect class. Needs the registry subset ported as shared data first; ordered JSON from this run is the substrate. | +| `GET /api/settings` | defer (live fields mixed into one body) | config-derived keys coexist in one object with `resolveCodexRuntime()` (memoized active-binary probing), `readStartupHealth(config)` (cached install-state probes) and `Intl…timeZone` (process zone). Can't split a single response object; not reproducible by a sidecar. | +| `GET /api/sidecar-settings` | defer (model-capability registry + candidate tables) | body = vision/web-search candidate rows from `findAnthropicVisionProvider` / `resolveVisionBackend` / `visionModelOptionsFor` / `webSearchCandidateRows` — provider model-capability registry + picker tables with live reachability checks (config-routes.ts:109, web-search-sidecar-options.ts:43). #17-scale registry port. | +| `GET /api/startup-health` | defer (cached install-state probe) | `readStartupHealth(config)` reads process-level cached install/service state (`invalidateStartupHealthCache` after actions). | +| `GET /api/diagnostics/project-config` | defer (process scan cache) | `getCachedProjectConfigDiagnostics()` — lazy scan cache in `src/codex/project-config-warnings.ts`; reimplementing scan+cache semantics is flip-scale. | +| `GET /api/update/check` | defer (updater state) | `checkForUpdate()` module state (update/job.ts). | +| `GET /api/update/status` | defer (job table + query param) | `readUpdateJob(jobId)` — in-memory job table; absent job id → 404 handled in-process. | +| `GET /api/windows-tray` | defer (platform probe) | non-win32 static body carries `process.platform`; win32 runs tray-action status probes. Cross-platform probe semantics, not a config read. | + +### #17 — model/provider/catalog reads (`model-routes.ts`, `provider-routes.ts`) + +| Route | Verdict | Reason (citation) | +|---|---|---| +| `GET /api/custom-models` | **Go-owned, strict** (this run) | raw config echo (see above) | +| `GET /api/models` | defer (live catalog) | `listManagementModelRows(config)` over the converged live catalog (fetchAllModels family). #17's own acceptance says "catalog reads reflect live state" — that lives with the catalog store at the flip. | +| `GET /api/catalog` | defer (persisted-catalog serializer) | `serializePersistedCatalog()` from `src/server/catalog-download.ts` — a large deterministic serializer over the Codex-converged catalog; port is flip-scale model-store work, plus corsHeaders sharing. | +| `GET /api/client-config` | defer (catalog rows) | rows over the converged catalog. | +| `GET /api/model-discovery` | **Go-owned, strict** | pure persisted config projection: policy, per-provider overrides, stored arrival/baseline rows, and `disabledModels`-derived state; no catalog/cache lookup. | +| `GET /api/selected-models` / `/api/model-presets` | defer (live catalog + discovery) | `getProviderLiveModelCount`, `materializeModelPreset` over the live catalog. | +| `GET /api/aliases` | defer (live /models cache) | `knownModelIdsForProvider` unions in `getStaleCached(provName)` (router.ts:99) — the last-known-good live /models cache; catalog drift handling (`builtinRule`) is registry-side. | +| `GET /api/providers` | defer (live keys in one body) | config-derived keys share one object with live `discovery` status and openai entitlement state. | +| `GET /api/provider-context-caps` | defer (in-memory caps module) | live context-capability state. | +| `GET /api/provider-presets` | defer (derive-provider-presets static table) | registry-derived preset table (`src/providers/derive.ts`) — static data port, moderate; folded into the registry-subset work that unblocks /api/config. | +| `GET /api/provider-request-pacing` | defer (live pacers) | `providerRequestPacingStatus` reads the module-level `pacers` map: queue depths, next-slot timestamps, last-start (src/providers/request-pacing.ts). `enabled` is config but is one key in a live body. | +| `GET /api/provider-quotas` | defer | live quota/usage cache (usage-quota surface, #20 family). | + +## Registry and oracle state after this run + +Four read routes are Go-owned: `/api/system/health` (volatile pid/uptime), +`/api/shadow-call-settings`, `/api/custom-models`, and `/api/model-discovery` (strict). The strict +trio exercises the empty-volatile contract against real wire bytes. Nothing in +`management-api.ts` names a route (pinned by test 7 of +`tests/go-ownership-plumbing.test.ts`); adding the next route stays a marker +flip + Go handler + oracle cases. + +## What unblocks the deferred reads at the flip + +- Registry static data as shared Go data (provider notes, codexAccountMode, + presets, alias rules) → then `/api/config`, `/api/provider-presets`, + `/api/aliases`' registry half. +- The live model store/catalog moves into the Go binary with the takeover + (#25 + the #40/#41 flip line) → `/api/models`, `/api/catalog`, discovery + views, sidecar-settings candidates become the binary's own state. +- Process-state routes (memory, update jobs, clamps, tray, diagnostics caches) + become the binary's own process state at the flip — no snapshot problem. diff --git a/devlog/_plan/260905_go_sidecar_takeover/033_auth_and_lab_gate_substrate.md b/devlog/_plan/260905_go_sidecar_takeover/033_auth_and_lab_gate_substrate.md new file mode 100644 index 0000000000..e5d7c1107b --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/033_auth_and_lab_gate_substrate.md @@ -0,0 +1,104 @@ +# 033 — Tickets #18/#19: Go management auth model + Lab activation gate/seam + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Status: implemented on `dev-go` +Tickets: +- [#18](https://github.com/waxiangzi/opencodex/issues/18) (spec #3: Go management auth/session model) +- [#19](https://github.com/waxiangzi/opencodex/issues/19) (spec #6: Lab activation gate + provider-slot in Go) +Owner decision (2026-09-05/06): implement both under the state-source gate (devlog 030); deferral decisions are made and recorded here, not re-escalated. + +## What these tickets are (and are not) + +Both are **substrate** tickets for later batches, not route flips. The TS front +door still admits every `/api/*` request before dispatch +(`src/server/index.ts` → `requireManagementAuth`), so a Go-owned route never +sees an unauthenticated request pre-flip, and the Go binary runs no Lab code. +The deliverables are the Go-side decision logic, the seams it registers into, +and machine proof that both answer identically to TypeScript — the write +batches (#21–#23) and the authorization gate (#26) consume #18 when they serve +writes, and #33 + the flip consume #19. Nothing here changes live behavior; a +default install is byte-identical to a build without these packages. + +## #18 — Go management auth/session model + +`go/internal/managementauth` reproduces the admission decision of +`src/server/management-auth.ts` + `src/lib/*-contract.ts` + +`src/server/gui-session.ts`: + +- **Admin token**: credential extraction (x-opencodex-api-key / + Authorization Bearer), timing-safe equality, env/file token resolution + (file shape `ocx_admin_[43]`, ≤512 bytes, no creation/ACL mutation — the + sidecar must not touch the parent's secret file). +- **Dashboard session**: `AuthorizeSession` mirroring + `authorizeGuiSessionRequest` — expiry deletion, server-origin comparison + through a port of `managementRequestOrigin` (loopback observed origin, + non-loopback requires api auth, hub public-origin override, WHATWG origin + serialisation incl. default-port dropping), browser-origin/CSRF rules for + safe vs. unsafe methods, remote-session sliding. +- **Capability principals**: all four process-scoped HMAC contracts + (system-restart, local-provider-reload, local-read, gui-pair) plus the + attestation proof, with exact payload strings, base64url-256 shapes, + allowlists, TTL windows, and the consumed-capability replay stores (256 + limit, gui-pair keyed by sha256 digest). +- **Gate**: principal ordering (capabilities → token → session) and the exact + rejection responses — 401 `{"error":"opencodex admin token required"}` and + 503 with reason + hint — as raw JSON bodies. + +State-source notes: the capability checks are pure functions of injectable +inputs; the token is env/disk state; the session table is owned by the serving +process (minted in-memory) so Go validation carries the table it is given and +mutates it the same way (expiry delete, sliding). Live enforcement lands with +the write batches / authorization gate; pre-flip this is proven substrate. + +## #19 — Lab activation gate + provider-slot seam + +- `go/internal/labactivation` reproduces `labActivationRequired` and + `labAutomationEnabledOnDisk`: routing profiles non-empty in config.json, or + automation enabled under `/lab/automation-config.json` + (`policy.enabled`, authority over the legacy `automation-policy.json`, + which is consulted only when the combined file is absent or carries no + policy object). The gate reads the same on-disk files the TS side reads. +- `go/internal/routing/compatibility` reproduces the core-owned + `provider-slot.ts` seam: a nullable evidence-provider reference with + set/resolve/detach-own-registration semantics. Typed opaquely until the Go + routing port; `labactivation.Activate` reproduces the composition-root + contract — required → register, not required → slot stays nil — proven with + stub providers (the real Lab evidence provider arrives with #33). + +## Proof + +- Go unit tests: `managementauth` (capability round trips incl. cross-mint + refusal, origin derivation, gate ordering, replay rejection, session + outcomes, token-file loader) and `labactivation` (gate fixtures, activation + registers only when required). +- **Differential oracle** `tests/go-auth-parity.test.ts`: the same ordered + vector arrays through `src/server/management-auth.ts` (in-process) and the + new `ocx-sidecar authcheck` subcommand (one Go Gate per array, so replay + stores persist across vectors like the TS module-level maps) — decisions + compared byte-for-byte: principal or exact status+body, plus the session + admission reason when probed. 7 suites, every principal + rejection path. +- **Differential oracle** `tests/go-lab-gate-parity.test.ts`: ten fixture + directories through `ocx-sidecar labcheck` and `src/lib/lab-activation.ts` + (Go reads the pristine fixture first, because the TS loader can repair a + config file in place) — automationEnabled/profilesNonEmpty/required equal. +- Gates: `go build/vet/test ./...` green; focused suites green. + +## Wire contract additions (ocx-sidecar) + +`ocx-sidecar authcheck ` and `ocx-sidecar labcheck ` are +differential-oracle subcommands, inert on the live path (the supervisor never +passes an argument). The authcheck JSON carries request/state/config/local per +vector; one Go process per array mirrors one serving process. + +## Residuals (recorded, not hidden) + +- #18's session *issuance*/pairing machinery (mint, grants, rate limits) is + not ported — validation is the ticket's acceptance; issuance is session-route + work at the write batch. #18's Go gate becomes live when Go serves management + requests without the TS front door (#21–#23 differential writes, #26 + authorization gate, flip). +- #19's real Lab evidence provider registration is #33's job; until then the + seam and gate are proven with stubs and no Go package imports Lab content. +- #26 remains the gate that makes "auth rejection paths match TypeScript" a + whole-surface property. diff --git a/devlog/_plan/260905_go_sidecar_takeover/034_hot_path_seam.md b/devlog/_plan/260905_go_sidecar_takeover/034_hot_path_seam.md new file mode 100644 index 0000000000..1537b09756 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/034_hot_path_seam.md @@ -0,0 +1,136 @@ +# 034 — Ticket #24: hot-path seam + streaming differential harness + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Status: implemented on `fix/ticket-24-hotpath-seam` (dev-go + 2) +Ticket: [#24](https://github.com/waxiangzi/opencodex/issues/24) (spec #4: hot-path seam + streaming differential harness) +Blocked-by (#13/#16): closed — differential-oracle infrastructure and shared Go config parsing landed on `dev-go`. + +## Scope discipline + +#24 is the **substrate** ticket of the hot-path increment (#4), exactly as #13 +was the substrate of the management read surface. It must NOT relay any real +provider traffic: the single-provider non-streaming relay (#27) and the SSE +streaming relay with frame parity (#29) are blocked *by* this ticket and own +that work. #24 therefore ships two things and nothing more: + +1. A declared **hot-path seam** in the sidecar that a later ticket replaces + provider-side without touching the front door again. +2. A **streaming differential harness** that compares ordered SSE frame + sequences across two live servers and normalises only declared volatile + fields. + +## Design decisions + +### 1. The seam is the same ownership pattern as the management surface, on the data plane + +- `src/server/hot-path-seam.ts` holds the DATA (one declared seam route: + `POST /v1/responses`), the independent activation gate + (`OPENCODEX_GO_HOTPATH_SEAM`), and a core-owned forwarder slot shaped like + `go-sidecar-slot.ts`. The route registry for the data plane is deliberately a + separate module from `management/route-registry.ts`: #4 user story 10 gates + the hot path separately from the management surface, and the management + registry's types (`mutates`, session exemptions) do not apply to `/v1/*`. +- `go-sidecar.ts` registers the hot-path forwarder at activation only when the + seam env is set — same ready-line handoff, same child-exit deregistration. + A sidecar attached without the seam env forwards nothing and `/v1/responses` + stays 100% in-process: the management surface can keep being migrated while + the data plane is untouched, and vice versa (independent rollback, #4 story 13). +- Default install: no sidecar, no seam env → zero behaviour change, and the + seam modules sit behind the same optional-subsystem rule as the Lab. + +### 2. The sidecar's hot-path seam serves `/v1/responses` from the TS oracle until a provider relay lands + +The seam handler in the sidecar (`go/internal/sidecar`) owns the *public +surface*: it authenticates the parent forward (request token), bounds the body, +and streams the response back. Its upstream today is a **private parent bridge** +(`/__ocx_go_sidecar/responses`) that runs the real in-process +`handleResponses` pipeline — byte-identical to a direct request because it IS +the same pipeline. #27/#29 replace the bridge as the seam's source per provider +without touching the front-door gate, the ownership data, or the harness. + +Streaming contract: status code, `content-type` and the body are relayed +byte-for-byte in stream order. The Go side must never buffer or re-frame the +stream: the harness's whole point is that a dropped, reordered or duplicated +SSE frame fails the differential. + +### 3. The bridge is authenticated with a body-bound parent claim, not client credentials + +The front door resolves data-plane admission before the seam gate (same +`resolveResponsesApiAuth` as the direct branch). The client credential never +crosses the process boundary; instead the front door mints a short-lived HMAC +claim over `admission | method | path | expiry | sha256(body)` using the same +per-activation write-relay secret already inherited by the sidecar, and the +bridge verifies it with a bounded replay store. The threat model is the +established sidecar one: a local process that can read the sidecar's environment +is already as privileged as the proxy process itself. + +### 4. Direct branch stays the oracle + +Server A (no sidecar) and Server B (sidecar + seam env) both talk to the same +deterministic fixture upstream. The harness captures the client-visible SSE +frame sequence from each and asserts ordered identity after applying the +declared volatile set — for the first fixture, an explicit empty set (raw byte +identity), with per-request JSON paths added only if a live run proves them +legitimately request-scoped. + +## Security boundary + +- The bridge endpoint verifies the bridge token AND the parent claim; it never + accepts an admin token or a client API key as a substitute (mirrors the + `provider-quotas` and write-relay bridge endpoints). +- The sidecar seam route answers 404 unless the parent request token is + present: the sidecar never invents a public data-plane listener of its own. +- No client credential, cookie, or browser header is forwarded past the front + door in either direction. + +## Proof + +- Go unit tests: seam route auth, body bound, bridge URL validation, streaming + passthrough of a synthetic fixture stream. +- `tests/go-hotpath-seam.test.ts`: differential oracle across two real servers + (skip-if-no-Go, same guard as `go-sidecar-parity.test.ts`), comparing ordered + SSE frames; a mutated fixture frame must fail. +- Existing suites stay green; `privacy:scan` stays green. + +## Delivery notes (filled in at close) + +Delivered on `fix/ticket-24-hotpath-seam` (two commits on top of `dev-go`): + +- `src/server/hot-path-seam.ts` — seam data (route + env gate + bridge path), + the body-bound parent claim (mint at the front door, verify at the bridge, + bounded one-use nonce table), and the private bridge object. The Go sidecar + never sees a client credential; it relays the claim verbatim. +- `go/internal/sidecar/hotpath.go` (+ tests) — the sidecar owns + `POST /v1/responses`: parent-token 404 gate, 256MB body bound, bridge URL + loopback validation, and byte-for-byte chunked stream relay with per-chunk + flush (frame order preserved by construction). +- `src/server/index.ts` — bridge endpoint (404 when the seam env is off), the + front-door seam gate inside the existing `/v1/responses` turn (default off; + no in-process fallback after the body read, so a dead seam is a retryable + 502, never a double-executed model call), and activation wiring. +- `src/server/go-sidecar.ts` — data-plane seam attachment state and the + seam forward hop, armed whenever the sidecar is attached and gated per + request by the env, so flipping the env at runtime is honoured. +- `tests/go-hotpath-seam.test.ts` — the streaming differential: two live + servers (in-process oracle vs seam) against one deterministic fixture + upstream must agree on the ordered SSE frame sequence. Declared volatile + set: the per-request trace header, `Date`, and the server CORS origin echo; + the body volatile set is EMPTY (raw frame identity). +- `tests/hot-path-seam.test.ts` — claim/bridge unit coverage incl. replay, + expiry, body-bound proof, oversized body, admission round-trip. + +Acceptance criteria: + +- [x] Hot-path seam exists in the sidecar (Go-owned `POST /v1/responses` + surface with a replaceable bridge source for #27/#29). +- [x] Streaming differential compares ordered frame sequences (frame + extractor + two-server oracle; reorder/drop classes proven non-vacuous). +- [x] Only declared volatile fields normalised (empty body volatile set; + declared header volatile set; the harness asserts the declaration). + +Verification: `go build/vet/test ./...`, `bun run typecheck`, +`privacy:scan`, and the four focused suites above are green. The repository +wide suite was attempted but stalled in this container on unrelated +OAuth/provider-management flakes (all six affected files pass standalone on +both this branch and `dev-go`); no seam-related file failed. diff --git a/devlog/_plan/260905_go_sidecar_takeover/035_write_surface_full_parity_gate.md b/devlog/_plan/260905_go_sidecar_takeover/035_write_surface_full_parity_gate.md new file mode 100644 index 0000000000..5a9e6a69f4 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/035_write_surface_full_parity_gate.md @@ -0,0 +1,259 @@ +# 035 — Ticket #26: write-surface full parity + authorization gate + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Status: implemented on `dev-go` at `05f1e9632` (merge `e4d684d11` + `05f1e9632` on top of #25 config test `befb594d2`) +Ticket: [#26](https://github.com/waxiangzi/opencodex/issues/26) (spec #3 capstone: "Write-surface full parity + authorization gate") +Parent spec: [#3](https://github.com/waxiangzi/opencodex/issues/3) (increment 3: write surface) +Blocked-by (#21/#22/#23): closed on `dev-go` — `eb4292e8f` relays the three +write batches through the sidecar. Read-side mirror: [#25](https://github.com/waxiangzi/opencodex/issues/25) +(full read-surface parity gate), in flight in a sibling worktree. + +Owner decision (2026-09-06): implement #26 as the **write-side parity gate** +under the state-source framework (devlog 030/032) — same shape the read +surface used. Three machine-checked deliverables: + +1. Every mutating route carries an explicit ownership verdict — Go-owned, + exempted, or deferred with a recorded reason. No silent plain route. +2. Every declared Go-owned write route has a state-reset differential oracle + case (response AND post-state, failure modes included). +3. Auth rejection parity as a whole-surface property: every declared Go-owned + write route is exercised without and with insufficient credentials/capability, + rejecting byte-identically to TypeScript. + +Making the Go `managementauth` gate *live* on the public write path (sidecar +answers requests without the TS front door) is deliberately **not** part of +#26: the sidecar never receives a browser session or admin token +(`go/internal/managementauth/write_relay.go`, devlog 033), and the flip (#41) +is where the Go binary becomes the serving process and owns that state. #26 +proves the decision substrate now so the flip can consume it wholesale. + +## Current write-surface state (HEAD `eb4292e8f`) + +- **Registry** (`src/server/management/route-registry.ts`): 122 mutating + routes total. 12 are declared Go-owned with `go: { relay: "signed", + volatileFields: [] }`; 18 carry an `exempt` verdict (CLI-parity vocabulary, + enforced honest by `tests/management-route-registry.test.ts`); **92 carry + neither** — the "silent plain" set this ticket eliminates. +- **Wire shape**: the TS front door (`src/server/index.ts` → + `requireManagementAuth`) still admits every `/api/*` request and resolves a + principal; the Go-owned write branch (`management-api.ts` → + `tryForwardDeclaredGoOwnedRoute`) mints a body-bound, one-use HMAC relay + claim (nonce/principal/method/path/sha256(body)/expiry, TTL 30 s, replay 256, + body cap 2 MiB); the sidecar public route verifies the parent request token + + claim (`go/internal/sidecar.go` `relayPublicWrite`), then forwards to the + private parent bridge `/__ocx_go_sidecar/write` + (`src/server/go-sidecar-write-relay.ts`), which re-verifies and dispatches + the **legacy TS handler** — TS remains the mutation oracle until native + mutations land, exactly like the #24 hot-path seam. +- **`managementauth` gate** (`go/internal/managementauth/`): substrate proven + by the `authcheck` differential oracle (`tests/go-auth-parity.test.ts`); it + never runs live pre-flip. `relayPublicWrite` rejects an invalid/absent claim + with a sidecar-shaped 401/404, which the front door makes unreachable from a + public client (the front door rejects first). +- **Coverage today** (`tests/go-sidecar-parity.test.ts`): state-reset + differential cases exist for the shadow-call write, settings write, + sidecar-settings write, and a quota/account-pool vector — not for all 12 + declared write routes, and no under-privileged write vectors at all. + +## What #26 is (and is not) + +#26 is the write-side capstone of spec #3: the machine property that the +*migrated* write surface is complete (no route can become Go-owned while its +authorization is unproven — spec #3 story 4), differentially proven per route, +and guarded by an authorization gate that answers identically to TypeScript. +It does **not** migrate new routes: the batches (#21/#22/#23) own which routes +move. It does **not** make the Go gate live: the flip (#41) owns that. It +makes both future steps safe to consume — the flip can serve the write surface +knowing every route already has a verdict, a differential, and proven +rejection parity. + +Acceptance criteria → deliverable map: + +| Acceptance | Deliverable | Seam | +|---|---|---| +| Every write route Go-owned | A write-ownership ledger making every mutating route's verdict explicit (Go-owned / exempt / deferred-with-reason), machine-checked against `MANAGEMENT_ROUTES` so no silent plain route survives a registry edit | 1 (registry/ledger) | +| State-reset differentials green for the write surface | One state-reset oracle case per declared Go-owned write route (the 12), response + post-state + a failure-mode leg where one exists | 2 (differential oracle) | +| Auth rejection paths match TypeScript | Every declared Go-owned write route exercised without and with insufficient credentials/capability through both TS in-process and the Go decision substrate; rejections byte-identical | 3 (authorization oracle) | + +## Seam 1 — the write-ownership ledger (verdict completeness) + +Where the read surface recorded per-route deferrals only in devlog 032, the +write surface needs the same decision **machine-checked**, because a write +route that silently lacks a verdict is exactly how a mutation could bypass its +guard later (spec #3 story 4: "a write route must never be Go-owned before its +authorization is proven" — the ledger makes the inverse visible too: a route +with no verdict cannot be argued about). + +Design: a pure-data ledger in a NEW module +(`src/server/management/write-ownership.ts`), because `route-registry.ts` is +pinned imports-nothing and a verdict ledger is data the core dispatch path +must never load. The ledger lists exactly the **deferred** mutating routes +(the set with neither a `go` marker nor an `exempt` today); go-owned and +exempt verdicts are read from the existing registry fields, never duplicated: + +- `go-owned` — derived from `route.go.relay === "signed"` (12 today). +- `exempt` — derived from the registry `exempt` reason; the existing honesty + tests already police it. +- `deferred` — one ledger row per remaining mutating route, carrying a `why` + (≥ the same non-trivial length bar as exemption reasons) and an optional + owner doc. Deferred is the explicit, reviewed state that replaces "silent + plain". + +A test then proves: the deferred ledger exactly covers the mutating routes +with neither marker (no extra, no missing, no plain); a route cannot gain a +`go` marker while its ledger row still exists (the existing marker-set pin +enforces the 12); adding a mutating route without a ledger row fails. The +exports stay inert data on the core path: nothing under `management-api.ts` +imports the new module. + +Verdict classification for the current 92 silent-plain routes follows the +write batches' own scope (spec #3: config writes, quota/usage, account-pool) +plus the state-source gate (devlog 030) for what stays TS-process-owned. A +route in the ledger is DEFERRED because no batch has claimed it: the three +batches claimed the pure config/account-pool writes (#21 settings trio, #22 +quota/usage, #23 account-pool verbs), and the deferred families are either +TS-process-owned state the flip will own (login flows and dashboard sessions, +OAuth device-code state, provider keychains, native-profile staging state +machines, storage job tables, system restart/tray/update actions, Codex Log +Guard protection) or config writes with live-catalog/registry residue the read +face already defers to the catalog/provider line (devlog 032: models, +providers, aliases, discovery). This is an ownership record, not a claim that +a relay is impossible — the relay executes the legacy TS handler, so the +honest boundary is batch scope and state ownership. The ledger row records the +family-level reason with concrete citations, mirroring 032's per-route table. + +## Seam 2 — state-reset differential coverage for the write surface + +Extend `tests/go-sidecar-parity.test.ts` so every one of the 12 declared +Go-owned write routes has its own state-reset oracle case: reset fixture bytes +→ apply the same mutation through Server A (in-process TS) and Server B +(sidecar attached, real Go) → compare status, headers, and body, then compare +the post-write on-disk config bytes. Where the route mutates no config (a +validation-only or account-store route under the fixture), the case proves the +response path and the no-write-on-error leg instead, and says so in the case +name. + +Delivered as three vectors sets in this file, one per declared write-route +group: + +- `codex-auth account-pool write vectors have a state-reset differential + oracle` — `PUT active` (pins + writes config), `PUT`/`PATCH pool-strategy` + (write config), `POST accounts/clear-cooldown` (no config post-state under + the empty fixture; clears in-process routing health, so the oracle proves + the response path and no-write leg), plus an invalid-strategy 400 leg that + leaves bytes untouched. +- `oauth account-pool and account-store vectors match through Go` — + `PATCH accounts/pool` (persists anthropicAccountPool), `PUT accounts/active` + (account-store route: under the empty fixture no OAuth account exists, so it + is the 404 no-write rejection path), `POST accounts/clear-cooldown` + (in-process health, no config post-state), plus an invalid-strategy 400 leg. + +The write legs also assert the file actually changed (`post-state ≠ initial`) +so a later byte equality is not vacuous. Under the fixture probe: +settings/shadow-call/sidecar-settings/codex-auth active/codex-auth +pool-strategy(PUT/PATCH)/oauth accounts pool(PUT/PATCH) write; the +clear-cooldown verbs return 200 `cleared:false` without writing (they clear +in-process routing health, absent under a fresh fixture); reset-credits/consume +and oauth accounts/active validate against account state and return +400/404 without writing. + +## Seam 3 — authorization gate: rejection parity as a whole-surface property + +The batch tests prove the happy path and the relay proof machinery +(`tests/go-sidecar-write-relay.test.ts`); #18 proves the decision substrate on +arbitrary vectors (`tests/go-auth-parity.test.ts`). Neither proves that the +*migrated write surface's own* rejection paths match TypeScript. Seam 3 adds +the write-surface authorization oracle: + +- For each declared Go-owned write route (the 12 method/path pairs), build the + under-privileged request set the front door admits or rejects on: no + credential, wrong admin token, and a valid system-restart capability aimed + at the write route's own method/path (proving a capability principal minted + for another route can never cross onto the write surface). Each vector set + also carries the admitting admin-token request so a false rejection on the + migrated surface would surface too. +- Feed identical vectors through TS in-process + (`src/server/management-auth.ts`, exactly as `go-auth-parity` does) and + through the Go gate via `ocx-sidecar authcheck`; compare byte-for-byte. + Because Go-owned write routes are forwarded only after front-door admission, + the *public* rejection is the front door's — but the flip will serve these + same routes with the Go gate as the front door, so the substrate decision on + the exact write-surface method/path pairs is what must match. That is what + this seam pins: principal-or-rejection equality on the write surface, not on + a generic vector. +- The relay's own rejection paths (bad claim / replay / expiry / altered body) + stay pinned by `go-sidecar-write-relay.test.ts`; seam 3 does not duplicate + them. + +The authorization oracle lives beside the existing parity tests as a new +`tests/go-write-surface-auth-parity.test.ts` (same skip-if-no-Go guard and +one-Go-process-per-array shape as `go-auth-parity.test.ts`). + +## Security boundary + +- Seam 1 adds an inert data module (`write-ownership.ts`) that nothing under + `management-api.ts` imports; it never routes traffic. `route-registry.ts` + still imports nothing at all. +- Seam 2/3 are differential oracles; they build the sidecar binary and boot + fixture servers but touch only throwaway `OPENCODEX_HOME` fixtures. No + credential, cookie, or browser header crosses a process boundary in the + tested relay paths (existing write-relay contract, unchanged). +- No client credential, dashboard session, or admin token is added to what the + sidecar receives. The Go gate stays substrate; nothing in this ticket makes + it reachable by an unauthenticated public caller. + +## Proof + +- Seam 1: new `tests/write-surface-ownership.test.ts` (ledger exactly covers + the mutating routes with neither marker; ledger rows must name real routes; + family reasons non-trivial; owner doc is a tracked repo file). +- Seam 2: `tests/go-sidecar-parity.test.ts` gains the codex-auth and oauth + vector sets above, completing one state-reset case per declared write route + (12 total) — every declared write route has one; the file passes 15 tests + (13 before this run, two fixture cases added). +- Seam 3: new `tests/go-write-surface-auth-parity.test.ts` (2 tests, 48 + + 24 vectors); TS in-process vs `ocx-sidecar authcheck` byte equality on the + write-surface vectors, plus the 503-unavailable state for the same surface. +- Gates: `go build/vet/test ./...` green under `go/`; focused Bun suites green; + `bun run typecheck` green. + +## Delivery notes (filled in at close) + +Delivered on `fix/ticket-26-write-auth-gate`, rebased onto `f8ab6d510` (dev-go: #21-23 + #24): + +- **035 decision record** (this doc) written first, then the three seams TDD'd + in order, each red-green before moving on. +- **Seam 1**: `src/server/management/write-ownership.ts` — 92 deferred verdict + rows across 15 owning-module families (script-generated from + `MANAGEMENT_ROUTES`, so no transcription drift), each `why` naming the real + state-source / batch-scope reason; `tests/write-surface-ownership.test.ts` + (4 tests) forces exact coverage: the ledger is precisely the mutating set + with neither a `go` marker nor an `exempt`, ledger rows must resolve to real + unclaimed mutating routes, reasons are non-trivial, and the owner doc is a + tracked repo file. Registry untouched; ledger imports nothing but a type. +- **Seam 2**: `tests/go-sidecar-parity.test.ts` gains two fixture cases + (codex-auth vectors: active / pool-strategy PUT+PATCH / clear-cooldown; + oauth vectors: pool PATCH / active-404 / clear-cooldown) with write legs + that assert the file changed so the byte equality is non-vacuous, plus + invalid-strategy 400 no-write legs on both faces. Every declared write route + (12) now has a state-reset differential case; the file passes 15 tests + (13 before, two fixture cases added). +- **Seam 3**: new `tests/go-write-surface-auth-parity.test.ts` (2 tests, + 75 expect calls) — per declared write route, no-credential / wrong-token / + cross-route capability / admin-token vectors through TS `management-auth` + vs `ocx-sidecar authcheck`, byte-identical; plus the same surface under + unavailable auth state (503 identical). +- Resolved mid-flight: the first ledger draft argued deferrals from a + "cannot relay / cannot differential" frame, which the write-relay architecture + refutes (the relay executes the legacy TS handler; `PUT /api/settings` + itself triggers `convergeCodexCatalog`). Rewrote every family reason around + batch scope + state ownership and updated the 035 verdict-classification + paragraph to match. +- Gates on this branch: `bun run typecheck` clean; focused suites + (write-surface-ownership, management-route-registry, go-ownership-plumbing, + go-sidecar-parity, go-sidecar-write-relay, go-auth-parity, + go-write-surface-auth-parity) green; `go vet` + `go test ./...` green under + `go/`. A `bun run test:changed` run showed only concurrency-timeout failures + in unrelated suites (codex-log-guard / lab / responses / abort-race), all of + which pass when re-run alone. diff --git a/devlog/_plan/260905_go_sidecar_takeover/036_nonstream_relay.md b/devlog/_plan/260905_go_sidecar_takeover/036_nonstream_relay.md new file mode 100644 index 0000000000..68fe4bf04f --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/036_nonstream_relay.md @@ -0,0 +1,179 @@ +# 036 — Ticket #27: non-streaming relay + response repair for one provider + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Ticket: [#27](https://github.com/waxiangzi/opencodex/issues/27) (spec #4: non-streaming relay + response repair for one provider) +Blocked-by (#24): closed — hot-path seam + streaming differential harness landed on `dev-go`. + +## Scope discipline + +#27 is the first **provider relay** ticket of the hot-path increment (#4): it +replaces the #24 private parent bridge as the seam's stream source for ONE +provider class on ONE transport shape. It must NOT relay streaming traffic +(that is #29), must NOT reproduce routing decisions beyond an unambiguous +single-provider subset (that is #30), and must NOT port every response repair +(that is #31). Everything outside the declared relay subset keeps the #24 +bridge, so the TypeScript oracle continues to serve it byte-identically and the +differential never compares a class the seam does not claim. + +The relay subset is deliberately narrow and is machine-pinned by the +differential plus the Go unit suites: + +1. **Transport**: non-streaming (`stream` is not `true`) `POST /v1/responses`. +2. **Provider**: one key-mode `openai-responses` provider whose Responses wire + needs no translation — the TS pipeline forwards the client request body + verbatim upstream (verified empirically for the simple-completion subset: + plain input, input arrays, declared function tools, reasoning effort, and + both the `configured-model-list` and `default-provider` route kinds). The + sidecar reproduces that forward byte-for-byte, including the provider + `Authorization` when an env/literal `apiKey` resolves. OAuth/forward, + keychain keys, custom `responsesPath`, combos, routing profiles, shadow + intercept and model namespace (`a/b`) requests never qualify — they stay on + the bridge. +3. **Repair**: the whole-body JSON response repair the TS pipeline applies to + bounded-JSON Responses answers (the field backfill: message/reasoning/call + item `id` synthesis, message `status` backfill, `output_text.annotations` + backfill) runs in Go on the relayed 2xx response. #31 later generalises this + into the full ordered repair chain for every rewrite; #27 owns the one + deterministic JSON transform that the bounded-JSON path applies today. +4. **Errors**: a non-2xx upstream answer with a non-empty body is relayed + verbatim (status, content-type, body) exactly like the TS passthrough; a + valid upstream `Retry-After` is preserved, an invalid one is dropped. + TS-only error surface — synthetic `Retry-After` defaults for 429s, empty-body + error envelopes, quota/cyber-policy classification, and pre-stream retry + loops — is NOT ported here: those are recovery semantics owned by the + routing ticket (#30) and remain a documented seam-period divergence. + +## Design decisions + +### 1. The relay rides the #24 seam behind its own env gate + +`OPENCODEX_GO_HOTPATH_RELAY` (declared in `src/server/hot-path-seam.ts`, read +by the sidecar) switches the seam's source for qualifying requests from the +bridge to the direct upstream relay. Default OFF: a default install — and any +install that has not proven a provider against the differential — is unchanged. +Independent rollback per spec #4 story 13: the management surface, the seam, +and the provider relay each carry their own gate. + +The gate is evaluated per request inside `dataPlaneSeam` AFTER the existing +front-door claim checks (the request token is still required; the seam still +never invents a public listener). A request that does not qualify falls through +to the bridge exactly as in #24, so the fallback is per-request, not global. + +### 2. The relay-safe predicate is a config + request contract + +Go claims a request only when it can prove the TS pipeline would forward it +verbatim and repair only the backfill: + +- Config-level refusals (bridge): combos table present, routing profiles + present, `shadowCallIntercept.enabled`, provider `authMode` not key-mode, + provider `apiKey` is a `keychain:` reference, custom `responsesPath`, + provider `adapter` not `openai-responses`, disabled provider. +- Route-level: the requested model (no `/`) must resolve through the TS simple + subset — a single enabled provider owning the model via `models` / + `defaultModel`, else the sole configured `defaultProvider` — mirroring + `routeModelInternal`'s `configured-model-list` / + `configured-default-model` / `default-provider` kinds in file order. +- Request-level: body is a JSON object; `stream` is not `true`; `model` is a + string; the body has none of the features that make TS rewrite the outbound + bytes or engage request-local state (`previous_response_id`, compaction + markers, namespaced/hosted tool entries, web-search/image/video plans); no + Codex pool/sub-agent/attestation headers on the request. + +The predicate is exercised by the differential matrix and by unit tests that +assert each refusal reason; the seam's honest fallback means a wrong refusal +costs parity (bridge still serves it), never correctness. + +### 3. Outbound request = the #24 openaiResponsesUrl contract + verbatim body + +The relay builds `POST /v1/responses` with the same +path normalization as `src/adapters/openai-responses-url.ts` (strip trailing +slashes / `/responses` / `/v1`, append `/v1/responses`), forwards the seam +request's body bytes verbatim, sets `content-type: application/json`, and adds +`Authorization: Bearer ` when the provider `apiKey` resolves through the +env (`${NAME}` / `$NAME`) or a literal. Loopback/private base URLs honor +`allowPrivateNetwork`; the request never goes through a system proxy (mirrors +the bridge transport). Keychain resolution is refused (bridge) because the +sidecar has no keychain access. + +### 4. Response = transport fidelity + the bounded-JSON backfill in Go + +For a 2xx JSON answer the relay applies the field backfill to an ordered JSON +tree and re-emits only when a field changed — byte-identical untouched +payloads (raw relay) and canonical re-serialisation on change, exactly like +the TS bounded-JSON path. The ordered tree and the ECMAScript +`JSON.stringify` encoder (string escaping, key order, V8 number formatting) +live in a new `go/internal/jsonwire` package; the transform mirrors +`src/server/responses/responses-field-backfill.ts` and its observed byte +behaviour, pinned by Go unit tests against golden payloads captured from the +TS oracle (message/reasoning/function/custom-tool id synthesis with +`_ocx_`, `status` inference from the response status, and +`annotations: []` on `output_text` parts). + +Non-JSON 2xx and non-2xx non-empty bodies are relayed verbatim with the +upstream content-type; valid `Retry-After` passes through. + +### 5. Proof that Go (not the bridge) served a claim + +The Bun differential asserts the fixture upstream sees the seam-served +request arrive from the Go process (`User-Agent: Go-http-client/…`) while the +in-process oracle's identical request arrives from Bun, and that both requests +carry the same method/path/content-type/`Authorization`/body. A gate-negative +request (e.g. `stream: true`) must arrive from the bridge (Bun UA), proving +the fallback still owns non-qualifying traffic. The Go seam unit suite proves +the same without a bridge: the seam answers a qualifying request while the +parent bridge is a dead port. + +## Security boundary + +- The relay activates only behind the existing seam request-token gate; it + adds no public listener and never reads a client credential — the front + door's body-bound claim headers are relayed to the bridge only, never to the + provider upstream. +- The provider API key is resolved from the config/env the operator already + trusts the sidecar with (the sidecar is a child of the proxy process); + keychain material is never requested (bridge instead). +- Outbound destinations are validated: only the configured provider base URL, + honoring `allowPrivateNetwork`; no proxy, no userinfo, loopback-only for the + fixture. + +## Proof (as landed) + +- `go/internal/jsonwire` unit tests: V8 number formatting against a committed + Bun-generated corpus (`testdata/v8-numbers.tsv`, 447 rows incl. exponent + window edges and random finite doubles), ECMAScript string escaping + (control escapes, literal U+2028/U+2029, no HTML escaping), ordered + round-trip / spread-set semantics, and number canonicalisation through + `Encode`. +- `go/internal/sidecar` field-backfill unit tests against committed goldens + produced by the REAL TypeScript repair + (`testdata/responses-repair-goldens.json`, 27 shapes: sparse message + canonical order, response-status → message-status mapping incl. + failed→incomplete / queued→in_progress, id namespace prefixes, + empty-string and non-string id replacement in place, compaction exclusion, + raw-bytes identity when nothing changed, number-literal canonicalisation on + change, U+2028 handling, key order). +- `go/internal/sidecar` relay unit tests: relay-safe predicate (every refusal + reason named), the seam direct relay with a dead bridge (proves no bridge + hop), outbound verbatim body + path + resolved `Authorization`, valid + `Retry-After` preserved / invalid dropped on a verbatim non-2xx relay, + oversized-body bound, streaming and gate-off requests staying on the + bridge. +- `tests/go-hotpath-relay.test.ts`: two-server differential over the + non-streaming matrix (plain input, tools array, reasoning, default-provider + fallback, plus a streaming refusal), where each armed-relay response equals + the in-process oracle byte-for-byte, the fixture upstream proves direct + serving via `User-Agent: Go-http-client/…` for relay-admitted requests and + Bun for refused/gate-off ones, and the relay gate off keeps every request + on the bridge. + +## Delivery notes (filled in at close) + +- Landed as a feature commit plus a "Merge ticket #27 …" merge on `dev-go`; + no PR (repository convention). TS `typecheck`, the focused suites + (`tests/go-hotpath-seam.test.ts`, `tests/go-hotpath-relay.test.ts`), + `go test ./...` and the full TS suite are green; `privacy:scan` is green. +- Seam-period divergences, all documented and owned by later tickets: + pre-stream retry loops, synthetic 429 `Retry-After`, empty-body error + envelopes, quota/cyber-policy classification, streaming relay (#29), + routing/combos/namespace handling (#30), full repair-chain parity (#31). diff --git a/devlog/_plan/260905_go_sidecar_takeover/037_ws_bridge_parity.md b/devlog/_plan/260905_go_sidecar_takeover/037_ws_bridge_parity.md new file mode 100644 index 0000000000..4ece418444 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/037_ws_bridge_parity.md @@ -0,0 +1,53 @@ +# 037 — Ticket #28: WebSocket bridge parity + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Ticket: [#28](https://github.com/waxiangzi/opencodex/issues/28) + +## Scope discipline + +This increment covers Responses WebSocket *frame production* only. Public +handshake admission, origin policy, capacity, socket ownership, cancellation, +logging and provider routing remain in Bun. Realtime/Live sockets and direct +provider WebSocket transport are outside this ticket. + +## Design decision + +We chose **(b), a Bun front door that forwards one authenticated client turn to +a Go loopback WebSocket endpoint**. Bun cannot transfer its accepted client +descriptor to another process and the Go standard library has no server +WebSocket package. The front door therefore retains the browser/Codex socket; +Go implements the small RFC6455 handshake/frame subset with the standard +library and calls a private parent bridge for the existing Responses pipeline. +It then emits the text and error frames that Bun copies unchanged to the +client. This proves Go produced the observable framing while retaining the +existing authority boundary. + +The route requires the per-activation parent request token. Go→parent calls +require the distinct bridge token. Neither hop carries an API key, cookie, or +other client credential. `OPENCODEX_GO_WS_BRIDGE=1` is independent and +default-off; it requires an attached sidecar. Failed bridge startup produces a +retryable frame before any provider turn begins. + +## Security boundary + +The Go listener remains loopback-only and 404s requests without the request +token. The private parent bridge verifies its bridge token. Maximum client and +Go text frames are 50 MiB, matching Bun's Responses WS policy. No payloads or +credentials are logged. + +## Proof (as landed) + +- Go's RFC6455 endpoint accepts a masked text request only after token-gated + upgrade, calls the parent bridge, and emits one text frame per SSE data + block, terminal stop behavior, JSON event synthesis, and structured errors. +- The Bun differential boots an in-process oracle and an attached Go sidecar, + captures every client text frame, and compares each UTF-8 payload byte for + byte across SSE, JSON, upstream error, malformed/incomplete streams and a + large frame. + +## Delivery notes (filled in at close) + +- Implementation keeps normal WebSockets on the existing Bun path unless the + explicit Go WS gate is enabled. Direct streaming relay, multi-turn cancel + propagation and Realtime/Live ownership remain for follow-up tickets. diff --git a/devlog/_plan/260905_go_sidecar_takeover/038_lab_routes.md b/devlog/_plan/260905_go_sidecar_takeover/038_lab_routes.md new file mode 100644 index 0000000000..6dd6d15bb6 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/038_lab_routes.md @@ -0,0 +1,48 @@ +# 038 — Ticket #33: Lab routes migration + differential + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Status: implemented +Ticket: #33 +Decision: [010 — migrate, do not cut](./010_lab_migrate_vs_cut_decision.md) + +## Verdict ledger + +The audit replaces the old blanket `local-transport` conclusion for literal reads. +`src/cli/lab.ts` does read the local SQLite projection directly, but the Compatibility +Matrix GUI independently fetches `GET /api/lab/*` through +`gui/src/pages/compatibility-matrix-api.ts`. Thus local CLI transport is not a reason +to leave the public browser transport TypeScript-owned. The pre-flip state oracle +remains TypeScript; Go owns transport through a parent capability bridge and does not +port the SQLite projection. + +| Route family | Verdict | Reason | +| --- | --- | --- | +| `GET /api/lab/automation`, `/runs` | Go now, strict | Literal routes. Dashboard-facing management transport; parent owns automation files/process state. | +| `GET /api/lab/artifacts`, `/catalog`, `/events`, `/observations`, `/production-signals`, `/public/community`, `/status`, `/subjects`, `/verdicts` | Go now, strict | Literal public reads used by the Compatibility Matrix. Parent bridge returns oracle bytes; no volatile fields are allowed. | +| `GET /api/lab/subjects/{id}`, `/events/{id}`, `/artifacts/{digest}` | Defer | Regex routes cannot be represented by `findGoOwnedManagementRoute`'s exact literal lookup. Their existing local CLI transport remains true, but is not the reason for the deferral. | +| Seven Lab writes, including automation and public evidence verbs | Defer | No CLI verb exists; the bounded `wp7` ownership record in `060_phase_gui_parity.md` remains authoritative. This ticket does not widen write relay scope. | + +## Delivery notes + +- Registry flips the eleven literal Lab GET rows to `go: { volatileFields: [] }`; + the three regex reads remain non-Go-owned. `read-surface-ownership.ts` records + the corresponding Go-now rows, keeping the all-read matrix exact. +- `go/internal/sidecar` serves only those exact routes after verifying the parent + request capability. It relays to `/__ocx_go_sidecar/lab-read`; the parent + verifies the child capability and re-enters `handleManagementAPI` with Go + forwarding disabled. This preserves the dynamic Lab import and the core/Lab + import boundary while avoiding a second SQLite implementation. +- `tests/go-lab-routes-parity.test.ts` builds with `CGO_ENABLED=0`, enables the + real Lab activation gate using a routing profile, starts an in-process oracle + and a sidecar-attached server, and compares status, content type, and raw + response bytes for all eleven routes. The unseeded projection deliberately + includes the `503 lab_projection_unavailable` response family. +- `tests/management-route-registry.test.ts` now pins the literal-vs-regex + judgement, including the strict empty volatile set. + +## Follow-up ownership + +The next Lab increment owns native Go projection/state and any expansion of the +exact-match route seam to parameterised routes. The existing `wp7` GUI-parity phase +owns Lab writes and CLI verbs. diff --git a/devlog/_plan/260905_go_sidecar_takeover/039_go_cli_scaffold.md b/devlog/_plan/260905_go_sidecar_takeover/039_go_cli_scaffold.md new file mode 100644 index 0000000000..2a5da6611a --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/039_go_cli_scaffold.md @@ -0,0 +1,36 @@ +# 039 — Ticket #35: Go CLI scaffold + local HTTP transport + version parity + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Ticket: [#35](https://github.com/waxiangzi/opencodex/issues/35) (spec #5) + +## Scope discipline + +This ticket adds a second CLI binary without moving the TypeScript CLI's operator workflow. The Go binary has a deliberately small registry: help, `--version`/`-v`/`version`, `health`, and `ready`. It drives only local unauthenticated identity endpoints. Starts, stops, configuration writes, management routes, wait/retry parity, and the complete command matrix remain owned by TypeScript until later slices; #36 owns the broader parity harness. + +## Design decisions + +### 1. Package manifest is the development version authority; release injection is authoritative outside a checkout + +TypeScript's `printVersion()` reads repository `package.json`. A Go binary built from this checkout walks upward from its current working directory to the same manifest, then prints exactly `opencodex \n`. That makes an ordinary source build match TypeScript immediately (2.42.0 for this ticket) without duplicating a version constant. + +Release builds set `main.version` with `-ldflags -X main.version=`; `OCX_VERSION` exists for controlled packaging environments. Both override the checkout fallback, so a distributed binary does not depend on a nearby source tree. Release tooling must derive that ldflag from the same package manifest used to publish the TypeScript CLI. A missing source manifest and missing injected version deliberately reports `0.0.0`, never a copied, stale package value. + +### 2. The command registry is data and parsing is local + +`internal/ocxcli.Commands` is the top-level registry used by help and tests. `Run` is injected with streams, runtime-record loading, HTTP client and challenge generation, allowing exact output and exit-code tests without a subprocess. The currently supported `--json` option is intentionally narrow; unknown or misplaced options return sysexits usage code 64 before discovery or HTTP work. + +### 3. Local transport first proves process identity, then reads readiness + +The CLI reads TypeScript-owned `OPENCODEX_HOME/runtime-port.json`, requiring a valid pid, port and 43-character attestation secret. `health` sends a fresh base64url challenge to `/healthz`, requires `status:"ok"`, `service:"opencodex"`, matching pid/port and verifies the response HMAC over `opencodex-local-management-v1\n\n\n`. It sends no admin token. `ready` first completes that health proof, then accepts `/readyz` only when its status/body pairing, service, version, pid and port match the existing TypeScript readiness contract. + +## Proof (as landed) + +- Go unit tests cover exact version output, registry shape, usage exit 64, healthy and invalid-attestation paths, and ready JSON output. +- `tests/go-cli-parity.test.ts` builds `cmd/ocx` when Go is available, compares Go and TypeScript version stdout byte-for-byte, then starts a real TypeScript proxy, writes its runtime record, verifies the TypeScript proof independently, and compares Go health identity JSON fields to that proxy. This is the small extension point for #36's command matrix. + +## Delivery notes (filled in at close) + +- Added `go/cmd/ocx`, `go/internal/ocxcli`, focused Go tests, the first Bun CLI differential, and the Go README capability note. +- Validation: `bun run typecheck`, `bun test tests/go-cli-parity.test.ts`, and `go fmt/vet/build/test ./...` from `go/`. +- Follow-on: #36 should expand differential coverage to the TypeScript CLI's full command and exit/output matrix, including wait and failure semantics. diff --git a/devlog/_plan/260905_go_sidecar_takeover/040_hotpath_routing.md b/devlog/_plan/260905_go_sidecar_takeover/040_hotpath_routing.md new file mode 100644 index 0000000000..7084c07409 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/040_hotpath_routing.md @@ -0,0 +1,11 @@ +# 040 — Go hot-path routing decisions (ticket #30) + +The new Go routing package is a pure state-snapshot kernel. The `routingcheck` sidecar subcommand runs it only for Bun differential tests; it is not a live sidecar route and the relay remains unchanged. + +The differential imports TypeScript as the oracle. It creates an isolated home, writes account credentials with `saveCodexAccountCredential`, seeds quota with `updateAccountQuota`, and clears quota, health, thread, rotation, and key-cooldown state around each vector. It calls `resolveCodexAccountForThread` for quota selection and a real 429 cooldown created by `recordCodexUpstreamOutcome`; key vectors call `rotateKeyOn429` and `getKeyCooldownUntil`. Each TypeScript decision is compared with JSON from the Go executable. + +Covered matrix: quota strategy active threshold rotation to lowest known usage; hard quota cooldown exclusion where another account is eligible; and key-pool 429 ring selection plus numeric Retry-After cooldown. The Go parser rejects non-TypeScript numeric forms such as `1e3`, `+5`, and `0x10`. + +Not yet covered or claimed: round-robin smooth-weight/sticky-success state, fill-first runtime cursor, unknown and plan-window quota scoring, all-unavailable sentinel behavior, priorities, affinity, reauth, scoped cooldowns, and soft avoid. A later parent-authorized state bridge must carry these state snapshots and successors before those paths can flip. + +The engine is intentionally not wired into the direct relay. The relay returns its first upstream 429, while TypeScript executes `rotateProviderTransportOn429` later in `handleResponses`, after owning route and config persistence. A Go retry returned 200 where the TypeScript oracle returned 429, so that wiring was reverted. diff --git a/devlog/_plan/260905_go_sidecar_takeover/041_cli_parity_harness.md b/devlog/_plan/260905_go_sidecar_takeover/041_cli_parity_harness.md new file mode 100644 index 0000000000..c5d58a5d7d --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/041_cli_parity_harness.md @@ -0,0 +1,34 @@ +# 041 — Ticket #36: CLI parity harness + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Ticket: [#36](https://github.com/waxiangzi/opencodex/issues/36) (spec #5: CLI parity harness) +Blocked-by (#35): merged — Go CLI scaffold + local HTTP transport landed on `dev-go`. + +## Scope discipline + +A reusable subprocess differential harness lives in +`tests/go-cli-parity.test.ts`. It builds `go/cmd/ocx` with `CGO_ENABLED=0`, runs +the TypeScript and Go CLIs with the same argv and an isolated `OPENCODEX_HOME`, +and compares stdout, stderr, and exit status byte-for-byte for Go-owned version +aliases, help, unknown-command, health, and ready paths. Live cases use a +shared local HMAC-attested loopback fixture; health's PID is normalized +narrowly because TS liveness deliberately returns an unkillable PID as `null` +while Go validates the attested PID. The matrix also names `status` and +`config show` as TS-only rows until their Go implementations exist, recording +the incomplete surface rather than treating it as parity. + +The harness runs in the Go CI job's differential-oracle step alongside the +existing sidecar oracle. It skips only when a local developer has no Go +toolchain; CI installs Go before executing it. + +The Go CLI observable surface was aligned with the TypeScript contract for full +help, command help, unknown-command output/exit status, health output, and +ready parser status messages. `skills/ocx` is generated solely from the +TypeScript capability registry and has no Go-specific surface, so no generated +skill file changed. + +## Verification + +`go test ./...`; `bun test tests/go-cli-parity.test.ts` (23 pass); +`bun run typecheck`; full `bun run test` suite. diff --git a/devlog/_plan/260905_go_sidecar_takeover/042_sse_stream_relay.md b/devlog/_plan/260905_go_sidecar_takeover/042_sse_stream_relay.md new file mode 100644 index 0000000000..455dae5d04 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/042_sse_stream_relay.md @@ -0,0 +1,73 @@ +# 042 — Ticket #29: streaming Responses relay + frame parity + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-06 +Ticket: [#29](https://github.com/waxiangzi/opencodex/issues/29) + +## Scope + +The Go data-plane relay now directly serves a narrow `stream: true`, +`openai-responses` subset. It incrementally frames upstream SSE, mirrors the +unconditional Responses field backfill, observes the first Responses terminal, +and preserves exact client bytes against the TypeScript tee-path oracle. Any +request outside this subset remains on the authenticated parent bridge. + +## Byte protocol + +The state machine retains incomplete transport bytes until it sees one of all +four legal blank-line delimiters: LF/LF, LF/CRLF, CRLF/LF, or CRLF/CRLF. +Complete blocks retain their original delimiter. A rewrite extracts and joins +`data:` lines, parses JSON only when valid, and replaces the first data line +only when the field backfill changed the event. CRLF blocks retain CRLF. An +unterminated EOF block is rewritten and synthetically delimited using its +newline style. + +`response.completed`, `response.failed`, and `response.incomplete` form the +client boundary. Frames after the first terminal are dropped. A pre-terminal +`data: [DONE]` is held until a terminal arrives; a terminal without an actual +DONE receives the conventional LF DONE frame. Clean EOF without a terminal +receives the adapter-EOF incomplete event plus DONE. Upstream read errors flush +the partial frame and append the static TypeScript failed-tail fallback because +Go and Bun transport error strings differ. + +The committed six-row oracle corpus exercises sparse object repair, truncated +and incomplete tails, malformed joined data containing DONE, CRLF delimiters, +and terminal incomplete. The Go tests run each row whole and byte-by-byte, and +also prove terminal ordering, post-terminal dropping, premature-DONE holding, +and the frame-size limit. + +## Admission + +Stream admission resolves the existing narrow route first and rejects provider +configuration that arms a client-visible transformation: + +- material `responsesItemIdRepair` (the empty object is inert), +- `responsesSnapshotRepair: true`, +- `statelessResponses: true`, or +- a case-insensitive match of the plan model in `preserveReasoningContentModels`. + +These checks apply only to streams. Non-stream behavior remains ticket #27's +whole-body relay. A successful `2xx text/event-stream` is incrementally +rewritten and flushed; non-SSE and non-2xx stream responses retain the previous +verbatim transport behavior. + +## JSON encoding + +`jsonwire` now uses ECMAScript own-property ordering when a repaired event is +re-serialized: canonical array-index keys from `0` through `4294967294` sort +numerically first, then all other keys retain document order. This pins the +sparse oracle's `"1"` key behavior. + +## Residuals + +- Cyber-policy `error` terminal classification is still TypeScript-owned. +- Malformed-output-index synthetic ids use the same process-global fallback + ordinal namespace as the TypeScript rewrite. +- Admission inspects saved Go-visible provider configuration, not every routed + merge nuance. +- The oracle is the TypeScript tee transport path; eager transport ordering is + not separately reproduced. +- Frame growth is bounded at 4 MiB; exact overflow recovery is intentionally + outside this narrow relay claim. +- The pre-existing undeclared-tool guard remains allowed only in the narrow + configuration where no declared-tool mismatch is involved. diff --git a/devlog/_plan/260905_go_sidecar_takeover/043_release_pipeline_go_artifacts.md b/devlog/_plan/260905_go_sidecar_takeover/043_release_pipeline_go_artifacts.md new file mode 100644 index 0000000000..cbf50ededb --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/043_release_pipeline_go_artifacts.md @@ -0,0 +1,48 @@ +# 043 — Ticket #42: release pipeline Go build (cross-compile) + CI switch + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-07 +Ticket: [#42](https://github.com/waxiangzi/opencodex/issues/42) (spec #7 acceptance: +release pipeline builds the Go binary for every target; CI verifies Go build/vet/test) + +## What landed + +The Go binary is the release runtime, so the release path builds and attaches the +same static cross-platform `ocx` artifact CI has verified: + +- `.github/workflows/go-release-artifacts.yml` (scaffolded at 36a0c2cfb as + dispatch-only staging) is now the active release-artifact gate. It runs on + pull requests and on pushes to `main`/`preview`/`dev` when the Go release + surface changes (same paths-filter `changes`-job shape as `ci.yml`), and + verifies every release target through `scripts/build-go-release-artifact.sh`: + `go build/vet/test` under `go/`, then the linux/amd64 artifact is built and + smoked — static ELF check, and `--version` must print exactly + `opencodex ` when run from a directory with no + package.json (proves the `-ldflags` stamp), which is what release.yml relies + on per release tag. The matrix job cross-compiles all five release targets + (linux/darwin × amd64/arm64, windows/amd64) with the same script and asserts + each file's format (ELF 64-bit / Mach-O / PE32+). +- `scripts/build-go-release-artifact.sh` keeps its single-builder role; its + header now states it is the shared artifact builder for the gate workflow and + release.yml rather than a staging-only helper awaiting #40 (closed). +- `release.yml`'s artifact build/attach steps (added with the #41 flip at + 310f25fa8) are unchanged in behavior; comments now tie them to #42's gate. + +## Why the CI switch stayed workflow-shaped + +`ci.yml` already runs a `go` job (build/vet/test + 6-target `ocx-sidecar` +cross-compile + differential oracles). The release *artifact* — `./cmd/ocx` with +version ldflags and the embedded dashboard — is a different build (script, +`-trimpath`, `sync-go-embedded-dashboard.sh`, 5 targets), and the release path +must not trust an unverified producer. A dedicated gate workflow keeps the +artifact verification next to its consumer (release.yml) instead of enlarging +the aggregate CI job graph; release remains a deployment of a commit the gate +already ran on, matching the repo's existing exact-SHA CI gate. + +## Verification notes + +Local verification: `go build/vet/test` clean under `go/`; the linux/amd64 +artifact builds through the script, prints the exact stamped version, and is a +static ELF; `tests/ci-workflows.test.ts` pins the workflow's permissions, +immutable action refs, bounded timeouts, trigger/paths-filter shape, and the +release.yml Go steps ordering. diff --git a/devlog/_plan/260905_go_sidecar_takeover/044_upgrade_rollback_golden_oracle.md b/devlog/_plan/260905_go_sidecar_takeover/044_upgrade_rollback_golden_oracle.md new file mode 100644 index 0000000000..5d90127534 --- /dev/null +++ b/devlog/_plan/260905_go_sidecar_takeover/044_upgrade_rollback_golden_oracle.md @@ -0,0 +1,131 @@ +# 044 — Ticket #43: upgrade-in-place + rollback drill + TS golden snapshot + +Unit: `260905_go_sidecar_takeover` +Date: 2026-09-07 +Ticket: [#43](https://github.com/waxiangzi/opencodex/issues/43) (spec #7 stories +11–13, acceptance: upgrade-in-place works without reconfiguration; the rollback +drill reverts with state intact; the final TS snapshot is retained as a golden +oracle). Parent spec: #7. Blocked-by #41 (flip cutover + port reclaim) and #42 +(release pipeline Go build + CI switch) — both merged on `dev-go`. + +## Scope discipline + +Two subprocess drills and one committed oracle fixture. Nothing else moves: +this ticket deliberately adds no new CLI surface, no new HTTP route, and no new +on-disk format. Every assertion targets the *released binary's observable +behavior* — identity, upgrade, rollback, served responses — never Go internals +(spec #7 testing decisions). + +The fixture driving both drills is `tests/go-upgrade-rollback-drill.test.ts` +(Bun differential harness in the same family as `tests/go-cli-parity.test.ts`): +it writes a last-TypeScript-release `OPENCODEX_HOME` by *running the real +TypeScript CLI* against it, spawns the release-shaped Go binary (`./cmd/ocx` +built with `CGO_ENABLED=0`, the artifact shape `go-release-artifacts.yml` +smokes), and shells in and out of the two runtimes under test. + +## What landed + +### Upgrade-in-place drill (story 11) + +Simulates the operator on the last TS release upgrading to the first Go +release, with state that only the TS runtime could have written: + +1. TS CLI `start` on a pinned free port in a fresh `OPENCODEX_HOME` whose + `config.json` carries a real fixture (providers, port, default provider). + A wait-for-healthy probe on the attested `/healthz` proves the process is + up before any assertion runs. (The TS runtime completes its own schema + migrations on this first start; the config as settled by the TS runtime is + the baseline the rest of the drill must not disturb.) +2. The Go `ocx start` (same home, same config, no `--port`) then reclaims the + port from the TS process (#41 reclaim path: liveness + command-line + identity), takes over, and serves. +3. Assertions on the *state the TS release left behind*: the settled + `config.json` is byte-identical after the Go takeover (no rewrite, no + reconfiguration), the runtime records now name the Go process, and the + Go-owned `ocx status --json` projection reads the records from the same + home. + +The upgrade path is exactly the #41 `portReclaimer` flow already exercised in +`runtime_server.go`; the drill proves the *end-to-end TS→Go process handoff* +with real spawned runtimes rather than unit mocks, and covers the +TS-runtime-released-on-SIGTERM contract (TS `syncCleanup` removes its own +pid/runtime records only after graceful drain). + +### Rollback drill (story 7, exercised per story 12) + +Shells *forward* to the Go runtime, then reverts to the TS CLI and asserts the +Go-written state is readable without data loss: + +1. Go CLI `config set` mutates `config.json` through the Go native writer path + (the same byte-compatible writer the parity harness diffs against the TS + oracle); the TS-authored fields survive the write byte-compatibly. +2. Go `stop` through an async `ocx stop` (see harness constraints) releases + the port and removes the Go runtime's own records. +3. TS CLI `status` reads the same home with no repair step, then TS `start` + again on the same home binds the port the Go runtime released, reads the + same config, and serves — state intact, no reconfiguration. +4. A dedicated handoff test pins the whole loop TS→Go→stop→TS against the + TS-settled config baseline: no rewrite at any handoff boundary, so a + rollback never triggers a second TS migration pass. + +### Final TS snapshot as golden oracle (story 13) + +A committed fixture that pins the *last TypeScript behavior surface* so a +post-flip parity regression is detectable even after the TS runtime is retired +from the release path: + +- `go/internal/ocxcli/testdata/pid-parse-oracle.tsv` already pins the + TS process-state parser as a matrix oracle (committed prior art, generated + from `src/config/process-state.ts` semantics). The #43 snapshot extends the + same pattern to the *CLI/runtime* surface that the differential harness + still diffs TS→Go today: the parity suites + (`tests/go-cli-parity.test.ts`, `tests/process-state-go-parity.test.ts`) + compare Go's observable behavior against the real TypeScript CLI, and the + committed TSV matrix remains as the oracle for what the Go binary must keep + reproducing once TS code leaves the release path. +- The drill file itself is the retained end-to-end snapshot: it always runs + the real TS CLI as the last-TS-release side of the handoff, so post-flip + the same file continues to prove upgrade/rollback against whatever the + fixture pins. + +## Why the state-fixture shape stayed process-shaped + +The alternative — synthesizing the TS-written state files directly — would +prove only that Go can *parse* TS-format files. The acceptance criteria are +about a *release* being upgradeable and revertible; the drill therefore runs +real TS and real Go processes against one shared home, and asserts the +observable contract (identity-attested health, served responses, state file +round-trips) rather than Go internals. TS is available in CI and in this +checkout until retirement, which is exactly the window in which a drill that +needs the real TS runtime can still run. + +## Why the CI wiring stays workflow-shaped + +`ci.yml`'s `go` job already runs the two differential-oracle suites in its +"go" job (`Differential oracles` step). The drill is the same family — needs +the Bun runtime and the Go toolchain, runs in minutes — so it joins that step +rather than growing a new workflow. Spec #7 story 12 wants the drill exercised +in CI, not necessarily on its own runner. + +## Verification notes + +Focused verification during development: the drill file standalone under +`bun test --timeout 60000` (matching the CI batch runner's per-file timeout) — +6 tests green (upgrade drill, rollback drill, byte-stable handoff, TS +snapshot-current test, Go-reproduces-snapshot test, buildable-and-named test); +`go test ./...` and `go vet ./...` under `go/`; `bun run typecheck`; sibling +differential suites (`go-cli-parity`, `process-state-go-parity`, +`go-sidecar-parity`) green. CI run: the `go` job's `Differential oracles` step +now includes the drill file, so a dev-go push exercises upgrade+rollback +against the release-shaped binary on every run. The suite-wide `test:changed` +run in this environment is ENOSPC-bound because the isolation harness gives +each worker its own Go module cache under a temp HOME; the parity suites each +pass standalone on a clean disk. + +Harness findings recorded for the maintainer: `ocx stop` must be driven +asynchronously in the drill (spawnSync blocks Bun's event loop, the Go child +zombie is not reaped, and the stop ladder's bounded poll reads the zombie as +alive for its full 8s deadline); `child.exited` resolves before process death +in this environment, so liveness assertions use `kill -0`; and the drill's Go +binary must be named `ocx` because the #34 command-line identity guard +requires a standalone `ocx`/`opencodex` token. diff --git a/devlog/_plan/260908_go_flip_restore_uninstall_boundary/010_boundary_record.md b/devlog/_plan/260908_go_flip_restore_uninstall_boundary/010_boundary_record.md new file mode 100644 index 0000000000..2518d6b608 --- /dev/null +++ b/devlog/_plan/260908_go_flip_restore_uninstall_boundary/010_boundary_record.md @@ -0,0 +1,107 @@ +# restore / uninstall / recover-history stay TypeScript-owned + +Status: open (unit closes when any of the three gains a byte-parity oracle) + +Origin: issue #52 continuation review — "flip connect/disconnect/restore/ +recover-history/uninstall to Go (state transactions)". `connect status` and +`disconnect` flipped in `ed8a2afab`. This unit records why the remaining three +local-teardown commands are **not** portable the way `disconnect` was, backed +by reproduction runs against the TypeScript CLI on seeded homes. + +## What each command actually does (source map) + +- `ocx restore [back]` — `src/cli/dispatch.ts` `restore:` case → + `restoreNativeCodexAsync` (`src/codex/inject.ts:1793`). Not a journal-only + rollback: it runs desired-state persistence (`setIntegrationEnabled`, + `src/codex/desired-state.ts`, recorded in `config.json.clientIntegrations`), + an ownership preflight (`inspectNativeCodexOwnership`), the Codex write + coordinator protocol (`codexWriteCoordinationEligibility` + + `withCodexWriteLock` + `beginTransition` — coordinator SQLite, tx receipts, + pre-image capture/compensation), `restoreCodexConfigInline`, catalog + artifact restore (`model_catalog_json`), an asynchronous history job + (`runCodexHistoryJob`), and `stripGrokConfig` (`src/grok/…`). `restore back` + is the reverse inject and needs a **live proxy** + model sync. +- `ocx uninstall` — `src/cli/index.ts` `handleUninstall`: platform service + manager stop/removal, identity-checked proxy shutdown with the tri-state + "proven down" probe (#3008), shim/tray/launcher removal, system env var and + shell hook removal, then the Codex/Grok native restore and the + ownership-metadata-gated config directory removal. +- `ocx recover-history` — `handleRecoverHistory` → + `runCodexHistoryJob({ operation: "recover-legacy-openai" })`: a + manifest-independent force-relabel of every user-message history row to + openai, executed through the history write-lock substrate over the real + Codex resume-history SQLite. + +## Reproduction evidence (2026-09-08, Linux, TS CLI `fa8adea97` head) + +Seed: temp `OPENCODEX_HOME` + `CODEX_HOME/codex` with an injected +`config.toml`, a process-owned `opencodex-journal.json`, and a minimal +`config.json` (providers/defaultProvider). + +`ocx restore --json` on that home (exit 0): + +- stdout envelope `artifacts.config.action="journal-restored"`, + `catalog.state="ok"`, `history.state="ok"`, rows 0. +- file deltas: `codex/config.toml` rewritten to the journal original; + `codex/opencodex-journal.json` removed; **added** + `config-mutation.sqlite` **and** `integrations/codex.json`; + `config.json` gained `clientIntegrations.codex: false` plus schema + defaults materialised. +- `integrations/codex.json` provenance entries carry `at` (wall clock) and + `txId` (UUID) — a second run on the same home emits a different timestamp, + and a Go implementation would emit different UUIDs/timestamps too. The + byte tree is **non-deterministic by construction**. + +`ocx uninstall` on the same home (exit 1, one failed step): + +- "not installed" for service/shim/tray steps (platform-manager probes), + native Codex restored (journal removed, config.toml rolled back, the same + `integrations/codex.json` OWN metadata written), then + `refused uninstall: config ownership metadata is missing or invalid` — the + config-directory removal is gated on fresh-install ownership metadata, so + the interesting legacy behavior is a refusal, not a file transaction. + +`ocx recover-history --legacy-openai --yes` on an empty-history home: +converged with 0 rows, exit 0, zero file deltas. With real history present it +is a SQLite migration over thread/rollout state that no stdlib-only Go port +can reproduce, and the whole command exists for pre-backup legacy recovery +that cannot be fixture-seeded faithfully. + +## Why the `disconnect` oracle pattern cannot carry these + +`disconnect` is a *closed set*: it either owns every artifact (journal owner +== connected key, token fingerprint match, catalog fingerprint match) or +refuses. Every write is deletion/restore of bytes we already hold, so TS vs +Go byte trees converge. The three commands above are *open sets* by design: + +1. **Non-deterministic bytes.** `integrations/codex.json` embeds `at` + timestamps and `txId` UUIDs; the coordinator database records + transition timing. Byte-diff oracles would need per-file semantic masks, + which is the "both halves agree with each other, both wrong" trap the + repo's parity discipline exists to prevent. +2. **Concurrent/asynchronous substrate.** The Codex write coordinator + (lock acquisition, adoption, tx publication) and the history worker run + outside the synchronous command; a tree snapshot races the worker. +3. **Platform surface.** `uninstall` touches service managers, the + autostart shim, shell hooks and system env vars; behavior differs per OS + and cannot close on a single-platform oracle. +4. **Live / legacy dependencies.** `restore back` requires a running proxy + and model sync; `recover-history` mutates real resume-history databases + that a fixture cannot faithfully stand in for. + +Repo rule applied (from the #38/#44 flip notes): never mark a command native +without a passing byte-parity oracle. `restore`, `uninstall`, and +`recover-history` remain TypeScript-owned in `go/internal/ocxcli/cli.go`; +the Go-owned surface for the family is exactly `ocx connect status` and +`ocx disconnect` (`ed8a2afab`). + +## Prerequisites for a future flip + +- Go-side `integrations/*.json` OWN-metadata writer plus the Codex write + coordinator and history-job substrate (modernc SQLite already lands in + `go/internal/configschema`; the coordinator/history layers do not exist). +- An oracle that either runs both CLIs against the same wall-clock window + and masks `at`/`txId`, or fixtures the coordinator empty and pins the + legacy-uncoordinated path — accepted deliberately, not by default. +- `uninstall` additionally needs per-OS oracle lanes (systemd/launchd/ + Windows Task Scheduler) before it can leave the service subsystem. diff --git a/devlog/_plan/260909_go_fork_release_line/000_grill_record.md b/devlog/_plan/260909_go_fork_release_line/000_grill_record.md new file mode 100644 index 0000000000..207d089308 --- /dev/null +++ b/devlog/_plan/260909_go_fork_release_line/000_grill_record.md @@ -0,0 +1,121 @@ +# Grill record — fork release line direction for the Go takeover + +Date: 2026-09-09 +Status: **decided — executing** (dogfood phase; archive to `_fin` once the Go +artifact ships through the release line) + +## What was grilled + +A `grilling`-skill session (design tree, round by round) stress-testing what +happens after the CLI flip close-out (#56/#58/#55 closed, #44 the only open +tracker). The driver question was the next-phase direction of the Go takeover +line, with everything downstream of it forced to be explicit. + +## Decision tree — each node and the owner's ruling + +1. **Grill object** → the *roadmap*: what the next batch of Go migration work + is and in what order. +2. **Milestone anchor** → *capability*: a phase is won when a user-visible + capability works without Bun, not when code structure is Go-shaped. + (Grounding fact: the most convincing verification of the day was the + runtime-probe tests turning green by themselves once the port-10100 + instance stopped.) +3. **Next-phase verb** → *(c) ship an installable artifact first* — build and + run `go/bin/ocx` (never built before), then decide use/coverage from the + artifact's real state. Rationale: (a) use-migration without a verified + build is jumping the unknown; (b) coverage expansion before any use + feedback is blind. +4. **Destiny of the fork `dev-go` line** → *(a) the fork is the main + battlefield* — the owner forks independently, releases their own versions, + and *considers donating upstream once mature*. Fork = final line; upstream + (lidge-jun/opencodex) = reference pulled, future donation target. +5. **ADR contradiction fix scope** → *(b) ADR-0008 + ADR-0009* — close the + contradiction inside the ADR asset; CONTEXT.md/AGENTS.md fork-reality prose + is operational guidance, a separate governance decision, not ADR business. +6. **Artifact action** → *(a) local build + smoke* — build `go/bin/ocx`, + verify the 42 Go-owned commands run natively, the 10 TS-owned print the + standalone delegation error, and the dev-mode delegation path works. +7. **Artifact destination** → *(c) dogfood first* — point daily `ocx` at the + Go binary, let real use surface the gap list, fix gaps, *then* ship + (also the accumulation basis for the upstream-donation maturity test). + +## Facts the tree rested on (all verified, not assumed) + +- **F1** — CLI registry 42 Go-owned / 10 TS-owned (Bun-dependent by design per + ADR-0009); oracle-able surface complete. +- **F2** — All takeover work lives on fork `dev-go`: 1033 ahead / 183 behind + `upstream/dev`; `go/` is 9 subsystems, ~57k lines. +- **F3** — ADR-0008's end state ("single binary at 100% differential parity") + was written for the upstream line; the delivered increment set is fork + tickets #1–#43, all closed — including #40 single-binary packaging, #41 + cutover (Go binary is the *release runtime*), #42 release pipeline, #43 + upgrade/rollback oracle. +- **F4** — Fork vs upstream relation had never been made explicit before this + session; zero PRs exist fork→upstream. +- **F5** — The sidecar/server face is far past "first read route": hot path, + non-streaming relay, ws bridge, SSE relay, Lab routes all closed. +- **F6** — Nobody had ever run the Go binary: `go/bin/` had no artifact, the + operating instance and daily CLI were the npm TS build. +- **F7** — Only open issue is #44 (seam tracker). +- **F8** — The fork's npm line already publishes on its own cadence: + `@bitkyc08/opencodex`, 233 versions, latest 2.48.0 (2026-09-08); upstream + has no npm package under `opencodex` (E404), so no version collision. +- **F9** — `release.yml` (post-#42) requires release tags to attach a + TypeScript-free single-binary Go artifact behind the `go-release-artifacts` + gate — but no such release was ever produced (`gh release` empty; npm tarball + has no Go binary). +- **F10** — The ADR contradiction, precisely: ADR-0008's "TypeScript CLI and + server remain the operating surface" vs the #41/#42 reality "Go binary is the + release runtime / release tags ship single-binary artifacts". ADR-0009 had + superseded the 100%-parity clause without ADR-0008 marking it. +- **F11** — Standalone Go binary on the 10 TS-owned commands prints "this + standalone ocx binary needs the TypeScript lifecycle owner…" (exit 1) + unless `OCX_TYPESCRIPT_CLI` points at a full distribution; dev-mode + delegation discovers `src/cli/index.ts` walking up from cwd. + +## Delivered (same session) + +- **ADR update** — commit `3803c2afd`: ADR-0008 gains a "Status update + (2026-09-09)" section (increment state #1–#43, operating surface now Go, + parity clause explicitly deferred to 0009, fork-line prose marker); ADR-0009 + gains a fork-line addendum tying its Bun-dependent list to the 10 standalone + delegation commands. +- **First artifact build + smoke** — `go/bin/ocx`: 20 MB static ELF + (CGO_ENABLED=0, `-ldflags -X main.version=2.42.0`). Smoke rows all green: + `--version` from any cwd; `help` (74 lines); `v2 status` native on an empty + home; standalone delegation error (exit 1, F11 text) from a non-repo cwd; + dev-mode delegation through `src/cli/index.ts` (exit 0, `Usage: ocx restore + [back]`); claude disabled gate byte-identical to the parity expectation + (exit 1); `login zai` running native (key slice, not delegation). +- **Dogfood switch** — `~/.local/ocx-dogfood/ocx` → `go/bin/ocx`, PATH + prepended in `~/.bashrc` (interactive shells now resolve `ocx` to the Go + binary, verified). Revert: delete the two trailing `.bashrc` lines, or call + `~/.bun/bin/ocx` explicitly. The npm install stays untouched. + +## Open observations during dogfood (gap list, to be filled by real use) + +- 42 Go-owned commands' daily feel (v2/claude/opencode/status/sync/…). +- The 10 TS-owned standalone error paths — with the caveat that running + *inside the repo cwd* silently delegates to Bun (dev mode); only outside the + repo does the standalone error surface. First open question this raises: + should any of the 10 become native (which are actually needed standalone)? +- Sidecar face: the artifact runs the CLI; the server face (`ocx start` via the + Go sidecar vs the TS server) has not been exercised as a daily surface. + +## Not yet decided (downstream of the gap list) + +- Release mechanics for the Go artifact (the never-run `release.yml` attach + path; GitHub release tags vs npm channel split). +- Fork governance prose in CONTEXT.md/AGENTS.md (deferred out of the ADR scope + ruling; operational guidance, needs its own decision round). +- The upstream-donation maturity test (what "mature" means — fed by dogfood + and shipped-release experience). + +## References + +- ADRs: `docs/adr/0008` (status update), `docs/adr/0009` (fork-line addendum). +- Fork ticket #44 (open) — the seam tracker whose body records the registry + state this direction builds on. +- Session commits on `dev-go`: `b7d969602` (claude flip), `ce4b30760` (update + archived), `8c582a851` (devlog archive), `0af23fe1a`/`7d241c60b` (style), + `868f433ee` (owner's spawn guard), `3803c2afd` (ADR update). diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index ac152db316..698d1f7a31 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -114,7 +114,7 @@ Exemple de structure abrégée : } ``` -L’objet réel comprend également `listen` (port, nom d’hôte, source du runtime et de la configuration), les diagnostics de chargement de la configuration et les diagnostics du plug-in Codex intégré. Le schéma JSON est uniquement extensible : de futures versions peuvent ajouter des champs, mais les champs existants doivent rester stables. Les clés d’API, jetons OAuth, en-têtes d’autorisation, contenus de requêtes, adresses électroniques et identités de compte en sont volontairement exclus. +L’objet réel comprend également `listen` (port, nom d’hôte, source du runtime et de la configuration), les diagnostics de chargement de la configuration et les diagnostics du plug-in Codex intégré. Pour l’artifact Go statique autonome, `runtime.source` vaut `"go-static"` ; `paths.runtime` est le chemin résolu de l’artifact (ou `"unknown"` lorsque la plateforme ne peut pas le résoudre) et `versionSkew.cliVersion` est la version de l’artifact estampillée lors de la compilation. La valeur `"bundled"` orientée Bun de l’exemple n’est donc pas la seule source d’exécution valide. Le schéma JSON est uniquement extensible : de futures versions peuvent ajouter des champs, mais les champs existants doivent rester stables. Les clés d’API, jetons OAuth, en-têtes d’autorisation, contenus de requêtes, adresses électroniques et identités de compte en sont volontairement exclus. ### `ocx health [--json]` diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index d6e9425b52..c36edb982b 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -113,7 +113,7 @@ ocx status --json } ``` -実際のオブジェクトには、`listen` (ポート、ホスト名、ランタイム/構成ソース)、構成ロード診断、およびバンドルされた Codex プラグイン診断も含まれています。 JSON スキーマは加算専用です。将来のバージョンではフィールドが追加される可能性がありますが、既存のフィールドは安定したままになるはずです。 API キー、OAuth トークン、認証ヘッダー、リクエスト コンテンツ、電子メール、アカウント ID は意図的に除外されます。 +実際のオブジェクトには、`listen` (ポート、ホスト名、ランタイム/構成ソース)、構成ロード診断、およびバンドルされた Codex プラグイン診断も含まれています。スタンドアロンの静的 Go artifact では、`runtime.source` は `"go-static"`、`paths.runtime` は解決済み artifact パス(プラットフォームが解決できない場合は `"unknown"`)、`versionSkew.cliVersion` はビルド時に刻印された artifact バージョンです。したがって、例にある Bun 向けの `"bundled"` は唯一の有効な runtime source ではありません。JSON スキーマは加算専用です。将来のバージョンではフィールドが追加される可能性がありますが、既存のフィールドは安定したままになるはずです。 API キー、OAuth トークン、認証ヘッダー、リクエスト コンテンツ、電子メール、アカウント ID は意図的に除外されます。 ### `ocx health [--json]` diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 081c791bbb..284cfebb14 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -141,10 +141,7 @@ ocx status --json } ``` -실제 객체에는 `listen`(포트, 호스트명, 런타임/설정 소스), 설정 로드 진단, 번들 Codex 플러그인 -진단도 포함됩니다. JSON 스키마는 추가만 허용합니다. 앞으로 버전에서 필드가 추가될 수는 있지만, -기존 필드는 안정적으로 유지되어야 합니다. 이 스키마는 API 키, OAuth 토큰, Authorization 헤더, -요청 내용, 이메일, 계정 식별자를 의도적으로 제외합니다. +실제 객체에는 `listen`(포트, 호스트명, 런타임/설정 소스), 설정 로드 진단, 번들 Codex 플러그인 진단도 포함됩니다. 독립 정적 Go artifact에서는 `runtime.source`가 `"go-static"`이고, `paths.runtime`은 확인된 artifact 경로(플랫폼에서 확인할 수 없으면 `"unknown"`)이며, `versionSkew.cliVersion`은 빌드 시 기록된 artifact 버전입니다. 따라서 예시의 Bun 지향 `"bundled"`는 유일하게 유효한 runtime source가 아닙니다. JSON 스키마는 추가만 허용합니다. 앞으로 버전에서 필드가 추가될 수는 있지만, 기존 필드는 안정적으로 유지되어야 합니다. 이 스키마는 API 키, OAuth 토큰, Authorization 헤더, 요청 내용, 이메일, 계정 식별자를 의도적으로 제외합니다. ### `ocx health [--json]` diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 3f3e286863..53d5411616 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -148,9 +148,13 @@ Abbreviated example shape: ``` The real object also includes `listen` (port, hostname, runtime/config source), config load -diagnostics, and bundled Codex plugin diagnostics. The JSON schema is additive-only: future versions -may add fields, but existing fields should stay stable. It intentionally excludes API keys, OAuth -tokens, authorization headers, request content, emails, and account identities. +diagnostics, and bundled Codex plugin diagnostics. For the standalone static Go artifact, +`runtime.source` is `"go-static"`; `paths.runtime` is the resolved artifact path (or `"unknown"` +when the platform cannot resolve it), and `versionSkew.cliVersion` is the build-stamped artifact +version. The Bun-oriented `"bundled"` value in the example is therefore not the only valid runtime +source. The JSON schema is additive-only: future versions may add fields, but existing fields should +stay stable. It intentionally excludes API keys, OAuth tokens, authorization headers, request content, +emails, and account identities. ### `ocx health [--json]` diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 30b4e627a3..e812f67c4e 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -150,11 +150,7 @@ ocx status --json } ``` -Реальный объект также включает `listen` (порт, hostname, источник runtime/config), диагностику -загрузки конфига и диагностику bundled-plugin'а Codex. JSON-schema только расширяемая: новые -версии могут добавлять поля, но существующие должны оставаться стабильными. Она намеренно не -включает API-key'и, OAuth-token'ы, заголовки авторизации, содержимое запросов, email и -идентификаторы аккаунтов. +Реальный объект также включает `listen` (порт, hostname, источник runtime/config), диагностику загрузки конфига и диагностику bundled-plugin'а Codex. Для автономного статического Go artifact `runtime.source` равен `"go-static"`; `paths.runtime` — это разрешённый путь artifact (или `"unknown"`, когда платформа не может его разрешить), а `versionSkew.cliVersion` — версия artifact, проставленная при сборке. Поэтому Bun-ориентированное значение `"bundled"` из примера не является единственным допустимым runtime source. JSON-schema только расширяемая: новые версии могут добавлять поля, но существующие должны оставаться стабильными. Она намеренно не включает API-key'и, OAuth-token'ы, заголовки авторизации, содержимое запросов, email и идентификаторы аккаунтов. ### `ocx health [--json]` diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index e26f4c8662..c63428db9e 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -159,12 +159,7 @@ Kısaltılmış örnek şekli: } ``` -Gerçek nesne ayrıca `listen` (port, ana bilgisayar adı, çalışma -zamanı/yapılandırma kaynağı), yapılandırma yükleme tanılamalarını ve paketlenmiş -Codex eklenti tanılamalarını içerir. JSON şeması yalnızca eklemelidir: -gelecekteki sürümler alanlar ekleyebilir, ancak mevcut alanlar kararlı -kalmalıdır. API anahtarlarını, OAuth belirteçlerini, yetkilendirme başlıklarını, -istek içeriğini, e-postaları ve hesap kimliklerini kasıtlı olarak hariç tutar. +Gerçek nesne ayrıca `listen` (port, ana bilgisayar adı, çalışma zamanı/yapılandırma kaynağı), yapılandırma yükleme tanılamalarını ve paketlenmiş Codex eklenti tanılamalarını içerir. Bağımsız statik Go artifact için `runtime.source`, `"go-static"` olur; `paths.runtime` çözümlenmiş artifact yoludur (platform bunu çözemiyorsa `"unknown"`) ve `versionSkew.cliVersion` derleme sırasında damgalanan artifact sürümüdür. Bu nedenle örnekteki Bun odaklı `"bundled"` değeri tek geçerli runtime source değildir. JSON şeması yalnızca eklemelidir: gelecekteki sürümler alanlar ekleyebilir, ancak mevcut alanlar kararlı kalmalıdır. API anahtarlarını, OAuth belirteçlerini, yetkilendirme başlıklarını, istek içeriğini, e-postaları ve hesap kimliklerini kasıtlı olarak hariç tutar. ### `ocx health [--json]` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index dfae403438..6e23670cee 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -113,7 +113,7 @@ ocx status --json } ``` -真实对象还会包含 `listen`(端口、主机名、运行时/配置来源)、配置加载诊断,以及 bundled Codex 插件诊断。JSON schema 仅允许追加字段:未来版本可能新增字段,但现有字段应保持稳定。它刻意不包含 API keys、OAuth tokens、授权头、请求内容、邮箱和账户身份。 +真实对象还会包含 `listen`(端口、主机名、运行时/配置来源)、配置加载诊断,以及 bundled Codex 插件诊断。对于独立静态 Go artifact,`runtime.source` 为 `"go-static"`;`paths.runtime` 为解析后的 artifact 路径(平台无法解析时为 `"unknown"`),`versionSkew.cliVersion` 为构建时写入的 artifact 版本。因此,示例中的面向 Bun 的 `"bundled"` 并非唯一合法的 runtime source。JSON schema 仅允许追加字段:未来版本可能新增字段,但现有字段应保持稳定。它刻意不包含 API keys、OAuth tokens、授权头、请求内容、邮箱和账户身份。 ### `ocx health [--json]` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index bb377466fd..beebc3af66 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -113,7 +113,7 @@ ocx status --json } ``` -實際物件還包含 `listen`(連接埠、主機名稱、runtime/config 來源)、設定載入診斷,以及 bundled Codex plugin 診斷。JSON schema 為附加式:未來版本可能新增欄位,但既有欄位應保持穩定。它刻意排除 API 金鑰、OAuth token、授權標頭、請求內容、電子郵件與帳號身分。 +實際物件還包含 `listen`(連接埠、主機名稱、runtime/config 來源)、設定載入診斷,以及 bundled Codex plugin 診斷。對獨立靜態 Go artifact,`runtime.source` 為 `"go-static"`;`paths.runtime` 是解析後的 artifact 路徑(平台無法解析時為 `"unknown"`),`versionSkew.cliVersion` 是建置時寫入的 artifact 版本。因此範例中的 Bun 導向 `"bundled"` 並非唯一合法的 runtime source。JSON schema 為附加式:未來版本可能新增欄位,但既有欄位應保持穩定。它刻意排除 API 金鑰、OAuth token、授權標頭、請求內容、電子郵件與帳號身分。 ### `ocx health [--json]` diff --git a/docs/adr/0008-go-runtime-incremental-takeover.md b/docs/adr/0008-go-runtime-incremental-takeover.md new file mode 100644 index 0000000000..466fc108f8 --- /dev/null +++ b/docs/adr/0008-go-runtime-incremental-takeover.md @@ -0,0 +1,53 @@ +# Reopen the Go runtime line as an incremental sidecar takeover + +The `dev2-go` Go port was retired on 2026-07-30 because a parallel runtime line +could not keep up with `dev` (594 commits of divergence) and kept producing silent +dogfood defects. We reopen Go native work in a different shape: the backend migrates +to Go as an incremental sidecar takeover — the Bun/TypeScript server stays the front +door while a Go sidecar takes over routes one at a time, and the endpoint is a single +static Go binary (server, CLI, and the embedded dashboard) with a byte-identical HTTP +API and on-disk formats. The owner will maintain the Go side long-term, which is what +makes the reopened line sustainable where the parallel line was not. + +## Considered options + +- **Parallel Go runtime line** — the `dev2-go` shape; rejected as already-failed. +- **Rust via N-API incremental module** — the previously stated default; not chosen + because the owner prefers Go. +- **Big-bang rewrite** — rejected; a single cutover cannot be verified against the + live TS oracle. + +## Consequences + +- Fresh Go codebase; `archive/dev2-go` is reference material only, not a fork. +- Parity is proven by a differential harness: the same request is run against the TS + and Go implementations and the responses (including SSE frame sequences) must match. +- The Compatibility Lab migrates last and is an explicit cut candidate. +- The flip to a single binary happens only at 100% differential parity; until then the + TypeScript CLI and server remain the operating surface. + +## Status update (2026-09-09): delivered, and the operating surface is now Go + +This ADR was written against the upstream (lidge-jun/opencodex) `dev` line. The Go +line is implemented on the owner's fork (`waxiangzi/opencodex`, branch `dev-go`), which +is since maintained as an independent release line; upstream is a future donation +target, not the current integration base. The prose below that still reads as upstream +"we" decisions should be read as fork-line decisions. + +Increment state on `dev-go` (fork tickets #1–#43, all closed): + +- The management read/write surface, hot path, non-streaming relay, WebSocket bridge, + SSE streaming relay, Lab routes, Go CLI scaffold + families, single-binary packaging + (#40), release pipeline (#42) and upgrade/rollback oracle (#43) are implemented with + their differential harnesses. The CLI surface is Go-owned for every oracle-able + command; the remainder is the explicit Bun-dependent list of ADR-0009. +- The final consequence bullet above ("TypeScript CLI and server remain the operating + surface") no longer holds: with the #41 cutover (dev-go, 2026-09-07) the Go binary is + the release runtime. Release tags ship a TypeScript-free single-binary artifact, the + `go-release-artifacts` workflow gates the exact binaries, and the Bun/TypeScript + server remains the in-repo oracle the differential harness diffs against — not the + shipped surface. +- The "100% differential parity" clause is superseded by ADR-0009's completion + definition (every oracle-able surface passes its oracle; the non-oracle-able surface + is the deliberate Bun-dependent list). ADR-0009 records the taxonomy; this ADR defers + to it. diff --git a/docs/adr/0009-deferral-oracle-taxonomy-and-bun-dependent-surface.md b/docs/adr/0009-deferral-oracle-taxonomy-and-bun-dependent-surface.md new file mode 100644 index 0000000000..93292818cc --- /dev/null +++ b/docs/adr/0009-deferral-oracle-taxonomy-and-bun-dependent-surface.md @@ -0,0 +1,19 @@ +# Deferral oracle taxonomy and the Bun-dependent surface + +ADR-0008 requires the flip to a single Go binary to happen only at "100% differential parity". Reproduction evidence (`devlog/_plan/260908_go_flip_restore_uninstall_boundary/010_boundary_record.md`) shows several TypeScript-owned surfaces are structurally incapable of byte parity: interactive OAuth (login/setup), OS service-manager lifecycle (service/tray), network self-replace (update), coordinator writes with non-deterministic bytes (restore/uninstall/recover-history), and launchers whose env depends on live auth subsystems (claude/opencode). Applied literally, the 100%-parity clause makes the delegation seam permanent and the standalone-artifact acceptance (spec #7 story 4) unreachable, so every flip chases an unattainable end state. + +We decided (owner, 2026-09-08): + +1. **Oracle taxonomy.** An oracle may be T1 byte parity (the existing harness), T2 masked parity (same wall-clock window, with explicitly declared volatile fields masked), T3 golden fixture (offline stubbed server / fixed home / pinned catalog), T4 platform lane (a per-OS test lane), or T5 interactive subset (the non-interactive branches of an interactive command). Non-T1 acceptance is per-surface, explicit, and recorded in the deferral ledger's track — never a default. +2. **Completion definition.** "100% differential parity" means every oracle-able surface passes its oracle. Surfaces that cannot be oracle-able form an explicit Bun-dependent list — the accepted end state, not an accident. The seam-removal target (#55) is re-scoped to shrink the ledger down to that list. +3. **Deferral reasons must be cross-checked against the TypeScript code.** The v2 deferral claimed a byte-exact TOML reader/writer was needed; the code writes config.toml only through the upstream `codex features` CLI, so the writer half was overstated. A deferral reason that names a missing primitive must cite the TS-side evidence it was derived from. + +**Status**: accepted — supersedes the literal reading of ADR-0008's parity clause. A flip still never lands without its oracle; the difference is which oracle shapes qualify. + +**Fork-line addendum (2026-09-09)**: with the #41 cutover the Go binary is the fork's +release runtime (ADR-0008 status update), so this taxonomy's Bun-dependent list is the +10 TypeScript-owned commands a standalone Go binary cannot serve natively — they print +the "needs the TypeScript lifecycle owner" delegation error unless `OCX_TYPESCRIPT_CLI` +points at the full distribution. The list is the accepted end state on the standalone +binary, exactly as #2 above intends; ADR-0008 now points here explicitly rather than +carrying its own superseded parity clause. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000000..b548c538d6 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000000..a52d774eb5 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,55 @@ +# Issue tracker: GitHub + +Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## Conventions + +- **Create an issue**: this repo requires a form template. Open issues through + the template chooser using one of `.github/ISSUE_TEMPLATE/` — + `bug_report.yml` (Bug report), `feature_request.yml` (Feature proposal), + `documentation.yml` (Documentation), or `provider_compatibility.yml` + (Provider or API compatibility) — and keep the form's `###` section headings + exactly as generated. The `enforce-issue-quality` gate closes freeform or + mismatched issues and blank issues are disabled, so a bare + `gh issue create --title … --body …` without the form headings will be + auto-closed. When scripting, reproduce the matching form's headings and + content in `--body` (or apply the matching kind label — `bug`, `enhancement`, + `documentation`, `provider-compatibility`). +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`). +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue using a form template (see Conventions). + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies** — the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only — the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me` — the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000000..b716855d48 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000000..6e9e4aab0d --- /dev/null +++ b/go/README.md @@ -0,0 +1,107 @@ +# Go sidecar — ADR-0008 incremental takeover, first increment + +A fresh Go module under the nested `go/` tree (module +`github.com/lidge-jun/opencodex/go`), per +[`docs/adr/0008-go-runtime-incremental-takeover.md`](../docs/adr/0008-go-runtime-incremental-takeover.md) +and the first-increment plan in +[`devlog/_plan/260905_go_sidecar_takeover/`](../devlog/_plan/260905_go_sidecar_takeover/). + +Nothing here is copied from `archive/dev2-go`; that archive is reference +material only. This is a fresh codebase. + +## What lives here + +- `cmd/ocx-sidecar` — the sidecar binary. The TypeScript server spawns and + supervises it when the operator sets `OPENCODEX_GO_SIDECAR_BIN` to a built + binary path; it serves the declared Go-owned management read routes with + byte-identical HTTP semantics to the in-process TypeScript handlers. Which + routes are Go-owned is DATA, not code: the ownership markers (and each + route's volatile-field declaration) live in + `src/server/management/route-registry.ts`, and the single forwarding branch + in `src/server/management-api.ts` reads them before asking the sidecar. +- `cmd/ocx` — the Go CLI scaffold (ticket #35) grown into the release + runtime: version, help, identity-attested local health / ready transport + commands, `start`/`stop` (flip, #41), and the embedded dashboard. Release + builds of `./cmd/ocx` are the TypeScript-free single-binary artifact (ticket + #42): `scripts/build-go-release-artifact.sh` stamps the package version via + `-ldflags` and embeds the Vite dashboard build, and the + `go-release-artifacts.yml` workflow verifies every release target with the + same script before `release.yml` attaches the binaries to a release tag. + The upgrade-in-place + rollback drill (ticket #43) runs the release-shaped + binary against the real TypeScript CLI on a shared home + (`tests/go-upgrade-rollback-drill.test.ts`): TS start → Go start reclaims + the port with the TS-settled config left byte-identical, Go stop releases + the home, and TS start takes it back with no reconfiguration. +- `internal/sidecar` — the handler plus its unit tests. The JSON key order and + number formatting of each payload are part of the byte contract with the Bun + differential oracle (`tests/go-sidecar-parity.test.ts`). +- `internal/config` — the shared Go config reader (ticket #16). It parses the + operator's `config.json` (OPENCODEX_HOME, defaulting to `~/.opencodex`) + exactly where the TypeScript runtime keeps it, so a Go-served read route + answers from the real on-disk state; route bodies are pure functions of the + subsection they read. +- `internal/managementauth` — the Go management admission model (ticket #18): + admin-token, dashboard-session, and capability-principal validation + mirroring `src/server/management-auth.ts` and the `src/lib/*-contract.ts` + HMACs, including the replay stores and the exact 401/503 rejection bodies. + Substrate: the TS front door still admits every management request pre-flip; + this gate is what Go uses when it answers without that front door (write + batches #21–#23, authorization gate #26, flip). +- `internal/labactivation` + `internal/routing/compatibility` — the Go + Compatibility Lab opt-in gate and the core-owned evidence-provider slot + (ticket #19), mirroring `src/lib/lab-activation.ts` and + `src/routing/compatibility/provider-slot.ts`. The gate reads the same + on-disk files the TS side reads (config.json routingProfiles, + lab/automation-config.json over the legacy automation-policy.json); the slot + registers only when the gate says the install uses Lab. Real Lab content + arrives with ticket #33. + +## Differential-oracle subcommands + +`ocx-sidecar authcheck ` evaluates admission vectors (request, state, +config, local context) through the real Go gate and prints decisions; the Bun +oracle (`tests/go-auth-parity.test.ts`) feeds the same arrays through +`src/server/management-auth.ts` and compares byte for byte. `ocx-sidecar +labcheck ` prints the Lab gate's three outputs for the Bun oracle +(`tests/go-lab-gate-parity.test.ts`). Both are inert on the live path: the +supervisor launches the sidecar with no arguments, so they only run when +invoked directly. + +## Building + +```bash +go -C go build ./cmd/ocx-sidecar +go -C go vet ./... +go -C go test ./... +``` + +The differential harness builds the binary itself with `CGO_ENABLED=0`; CI +does the same (the `go` job in `.github/workflows/ci.yml`). The module has no +external dependencies, so there is no `go.sum`. + +## Wire contract with the TypeScript parent + +- The parent passes the installed package version in `OCX_SIDECAR_VERSION`; + the sidecar reports it verbatim as the `version` field (fallback `0.0.0`). +- After binding its loopback listener, the sidecar prints one readiness line on + stdout: `ocx-sidecar-ready http://127.0.0.1:`. The parent waits for + this line before registering the route forwarder. +- The migrated route's declared volatile fields are normalised by the + differential oracle and nothing else is: a later route cannot silently widen + what parity means. The declaration lives with the route in + `route-registry.ts`, not here. Today: `GET /api/system/health` declares + `["pid", "uptime"]` (the sidecar reports its own process values), and + `GET /api/shadow-call-settings` declares an EMPTY set — its body is a pure + function of `config.json`, so the oracle compares raw bytes with no + normalisation at all. + +## Config read routes (ticket #16) + +`GET /api/shadow-call-settings` is the first config read route served from Go. +The sidecar reads the same `config.json` the TypeScript in-process handler's +config snapshot came from (`internal/config`), then projects the +`shadowCallIntercept` section through the same rules the TS handler applies +(`shadowSourceModels` defaults, trim/non-empty filtering, `sci.model ?? ""`). +The config is read per request; the sidecar carries no state. The in-process +TS handler remains the fallback and the differential oracle, so a default +install and a supervision blip behave byte-identically to a build without Go. diff --git a/go/cmd/ocx-sidecar/authcheck.go b/go/cmd/ocx-sidecar/authcheck.go new file mode 100644 index 0000000000..234ba9bd42 --- /dev/null +++ b/go/cmd/ocx-sidecar/authcheck.go @@ -0,0 +1,201 @@ +package main + +// The authcheck subcommand is the differential-oracle entry point for the Go +// management auth model (ADR-0008, ticket #18): it evaluates one or more +// admission vectors in a single process — so the per-capability replay stores +// behave exactly like the TS module-level maps — and prints the decisions as +// JSON. tests/go-auth-parity.test.ts feeds the same vector arrays through +// src/server/management-auth.ts and through this subcommand and compares the +// outputs byte for byte. The subcommand is inert on the live path: ocx-sidecar +// runs it only when invoked as `ocx-sidecar authcheck`, which the supervisor +// never does. + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/lidge-jun/opencodex/go/internal/managementauth" +) + +// authVector mirrors the request/state/config/local slice the oracle test +// builds from the TypeScript side. +type authVector struct { + Request authRequest `json:"request"` + State authState `json:"state"` + Config authConfig `json:"config"` + Local authLocal `json:"local"` + Probe bool `json:"sessionProbe,omitempty"` +} + +type authRequest struct { + URL string `json:"url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` +} + +type authSessionEntry struct { + Token string `json:"token"` + ServerOrigin string `json:"serverOrigin"` + BrowserOrigin string `json:"browserOrigin"` + CSRF string `json:"csrf"` + ExpiresAt int64 `json:"expiresAt"` + Issuance string `json:"issuance"` +} + +type authState struct { + Available bool `json:"available"` + Token string `json:"token"` + Source string `json:"source"` + Reason string `json:"reason"` + Sessions []authSessionEntry `json:"sessions"` +} + +type authConfig struct { + Hostname string `json:"hostname"` + RuntimeRole string `json:"runtimeRole"` + HubManagementPublicOrigin string `json:"hubManagementPublicOrigin"` +} + +type authLocal struct { + AttestationSecret string `json:"attestationSecret"` + PID int `json:"pid"` + Port int `json:"port"` +} + +type authDecision struct { + Admitted bool `json:"admitted"` + Principal *string `json:"principal"` + Rejection *authRejection `json:"rejection"` + SessionState string `json:"sessionState,omitempty"` +} + +type authRejection struct { + Status int `json:"status"` + Body string `json:"body"` +} + +func runAuthCheck() error { + // The vectors arrive as a JSON argv element (the oracle test passes them on + // the command line so Bun.spawnSync can stay synchronous); stdin is the + // fallback for direct shell use. + var raw []byte + if len(os.Args) > 2 { + raw = []byte(os.Args[2]) + } else { + var err error + raw, err = io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("authcheck: read stdin: %w", err) + } + } + var vectors []authVector + if err := json.Unmarshal(raw, &vectors); err != nil { + return fmt.Errorf("authcheck: decode vectors: %w", err) + } + + // One gate per invocation, mirroring one serving process. All vectors share + // it so capability consumption persists across the array exactly as the TS + // module-level stores do within one oracle run. + gate := buildGate(vectors) + decisions := make([]authDecision, 0, len(vectors)) + for _, vector := range vectors { + req := managementauth.Request{ + URL: vector.Request.URL, + Method: vector.Request.Method, + Header: lowerHeaders(vector.Request.Headers), + } + decision := gate.Admit(&req) + out := authDecision{} + if decision.Principal != "" { + out.Admitted = true + principal := string(decision.Principal) + out.Principal = &principal + } else { + out.Rejection = &authRejection{Status: decision.Rejection.Status, Body: decision.Rejection.Body} + } + if vector.Probe { + sessionState := "missing" + if admission := managementauth.AuthorizeSession( + &req, + vectorConfig(vector), + sessionsCopy(gateState(gate)), + time.Now().UnixMilli(), + ); admission.OK { + sessionState = "ok" + } else { + sessionState = string(admission.Reason) + } + out.SessionState = sessionState + } + decisions = append(decisions, out) + } + encoded, err := json.Marshal(decisions) + if err != nil { + return fmt.Errorf("authcheck: encode decisions: %w", err) + } + fmt.Println(string(encoded)) + return nil +} + +func lowerHeaders(headers map[string]string) map[string]string { + if headers == nil { + return map[string]string{} + } + out := make(map[string]string, len(headers)) + for name, value := range headers { + out[strings.ToLower(name)] = value + } + return out +} + +func vectorConfig(vector authVector) managementauth.ConfigView { + return managementauth.ConfigView{ + Hostname: vector.Config.Hostname, + RuntimeRole: vector.Config.RuntimeRole, + HubManagementPublicOrigin: vector.Config.HubManagementPublicOrigin, + } +} + +func buildGate(vectors []authVector) *managementauth.Gate { + if len(vectors) == 0 { + return managementauth.NewGate(managementauth.State{Available: false, Reason: ""}, managementauth.ConfigView{}, managementauth.LocalContext{}) + } + first := vectors[0] + state := managementauth.State{ + Available: first.State.Available, + Token: first.State.Token, + Source: first.State.Source, + Reason: first.State.Reason, + Sessions: map[string]managementauth.Session{}, + } + for _, entry := range first.State.Sessions { + state.Sessions[entry.Token] = managementauth.Session{ + ServerOrigin: entry.ServerOrigin, + BrowserOrigin: entry.BrowserOrigin, + CSRF: entry.CSRF, + ExpiresAt: entry.ExpiresAt, + Issuance: entry.Issuance, + } + } + return managementauth.NewGate(state, vectorConfig(first), managementauth.LocalContext{ + AttestationSecret: first.Local.AttestationSecret, + PID: first.Local.PID, + Port: first.Local.Port, + }) +} + +func gateState(gate *managementauth.Gate) map[string]managementauth.Session { + return gate.Sessions() +} + +func sessionsCopy(sessions map[string]managementauth.Session) map[string]managementauth.Session { + out := make(map[string]managementauth.Session, len(sessions)) + for token, session := range sessions { + out[token] = session + } + return out +} diff --git a/go/cmd/ocx-sidecar/labcheck.go b/go/cmd/ocx-sidecar/labcheck.go new file mode 100644 index 0000000000..0f02718a58 --- /dev/null +++ b/go/cmd/ocx-sidecar/labcheck.go @@ -0,0 +1,47 @@ +package main + +// The labcheck subcommand is the differential-oracle entry point for the Go +// Lab activation gate (ADR-0008, ticket #19): given a config directory, it +// prints the gate's three inputs and decision the way tests/go-lab-gate- +// parity.test.ts compares them against src/lib/lab-activation.ts. Inert on the +// live path, like authcheck. + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/lidge-jun/opencodex/go/internal/config" + "github.com/lidge-jun/opencodex/go/internal/labactivation" +) + +type labGateResult struct { + AutomationEnabled bool `json:"automationEnabled"` + ProfilesNonEmpty bool `json:"profilesNonEmpty"` + Required bool `json:"required"` +} + +func runLabCheck() error { + if len(os.Args) < 3 { + return fmt.Errorf("labcheck requires a config directory argument") + } + configDir := os.Args[2] + cfg, err := config.LoadFromDir(configDir) + if err != nil { + // The gate must still answer for a config.json the TS side would + // salvage: report on what the loader could read (an empty document). + cfg = &config.Config{Raw: map[string]any{}} + } + automation := labactivation.AutomationEnabledOnDisk(configDir) + profiles := labactivation.ProfilesRequireActivation(cfg.Raw["routingProfiles"]) + encoded, err := json.Marshal(labGateResult{ + AutomationEnabled: automation, + ProfilesNonEmpty: profiles, + Required: automation || profiles, + }) + if err != nil { + return fmt.Errorf("labcheck: encode: %w", err) + } + fmt.Println(string(encoded)) + return nil +} diff --git a/go/cmd/ocx-sidecar/main.go b/go/cmd/ocx-sidecar/main.go new file mode 100644 index 0000000000..b0fceff6f2 --- /dev/null +++ b/go/cmd/ocx-sidecar/main.go @@ -0,0 +1,115 @@ +// Command ocx-sidecar is the first Go-owned process of the incremental +// runtime takeover (ADR-0008). It is spawned and supervised by the +// TypeScript proxy front door and serves the declared Go-owned read-only +// management routes (today: GET /api/system/health and +// GET /api/shadow-call-settings) with byte-identical HTTP semantics to the +// in-process TypeScript handlers. See go/internal/sidecar for the contract. +// +// The binary is built CGO_ENABLED=0 and carries no state: everything it must +// echo from the parent (service label, package version) arrives through the +// environment at spawn time, and the config read route reads the operator's +// config.json from the same OPENCODEX_HOME the parent was launched with. +package main + +import ( + "fmt" + "net" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/lidge-jun/opencodex/go/internal/sidecar" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "ocx-sidecar:", err) + os.Exit(1) + } +} + +func run() error { + // Differential-oracle subcommands (ADR-0008 tickets #18/#19). The + // supervisor never passes an argument, so the live sidecar path is + // unaffected; these exist so the Bun oracle can evaluate the same request + // vectors and Lab-gate fixtures through the real Go code. + if len(os.Args) > 1 { + switch os.Args[1] { + case "authcheck": + return runAuthCheck() + case "labcheck": + return runLabCheck() + case "routingcheck": + return runRoutingCheck() + case "processstatecheck": + return runProcessStateCheck() + } + return fmt.Errorf("unknown subcommand %q", os.Args[1]) + } + return serve() +} + +func serve() error { + // Bind first, announce second: the parent only starts forwarding once it + // has read the ready line, so announcing a listener that failed to bind + // would leave the front door waiting on a dead child. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("listen: %w", err) + } + addr := listener.Addr().String() + + cfg := sidecar.Config{ + Service: "opencodex", + Version: os.Getenv("OCX_SIDECAR_VERSION"), + StartedAt: time.Now(), + ParentURL: os.Getenv("OCX_SIDECAR_PARENT_URL"), + BridgeToken: os.Getenv("OCX_SIDECAR_BRIDGE_TOKEN"), + RequestToken: os.Getenv("OCX_SIDECAR_REQUEST_TOKEN"), + WriteRelaySecret: os.Getenv("OCX_SIDECAR_WRITE_RELAY_SECRET"), + HotPathRelay: os.Getenv(sidecar.HotPathRelayEnv) != "", + } + shutdownTracker := sidecar.NewShutdownTracker() + cfg.ShutdownTracker = shutdownTracker + shutdownTimeout := sidecar.ParseShutdownTimeout(os.Getenv(sidecar.ShutdownTimeoutEnv)) + if cfg.Version == "" { + fmt.Fprintln(os.Stderr, "ocx-sidecar: warning: OCX_SIDECAR_VERSION is unset; reporting version 0.0.0") + } + + server := &http.Server{ + Handler: sidecar.NewHandler(cfg), + ReadHeaderTimeout: 5 * time.Second, + // Health responses are tiny; an idle client must not pin a socket. + IdleTimeout: 30 * time.Second, + } + + // The readiness contract: exactly one line on stdout, " http://:". + // The TypeScript supervisor (src/server/go-sidecar.ts) waits for this line before it + // registers the sidecar as the owner of GET /api/system/health. + fmt.Printf("%s http://%s\n", sidecar.ReadyLinePrefix, addr) + + serveErr := make(chan error, 1) + go func() { + serveErr <- server.Serve(listener) + }() + + // Terminate cleanly on SIGTERM/SIGINT so the supervising front door can + // stop the sidecar without a zombie or a half-written health response. + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) + select { + case sig := <-signals: + fmt.Fprintf(os.Stderr, "ocx-sidecar: received %s; shutting down\n", sig) + if err := sidecar.ShutdownServer(server, shutdownTracker, shutdownTimeout); err != nil { + return fmt.Errorf("shutdown: %w", err) + } + return nil + case err := <-serveErr: + if err == nil { + return nil + } + return fmt.Errorf("serve: %w", err) + } +} diff --git a/go/cmd/ocx-sidecar/processstatecheck.go b/go/cmd/ocx-sidecar/processstatecheck.go new file mode 100644 index 0000000000..f9ea2ae411 --- /dev/null +++ b/go/cmd/ocx-sidecar/processstatecheck.go @@ -0,0 +1,58 @@ +package main + +// The processstatecheck subcommand is the differential-oracle entry point for +// the Go process-state model (ticket #34). It calls the production parser and +// command-line matcher directly, so the Bun test cannot accidentally validate +// a second implementation of either security-sensitive rule. + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/lidge-jun/opencodex/go/internal/ocxcli" +) + +type processStateCheckInput struct { + Parse []string `json:"parse"` + Match []string `json:"match"` +} + +type processStateCheckOutput struct { + Parse []int64 `json:"parse"` + Match []bool `json:"match"` +} + +func runProcessStateCheck() error { + var raw []byte + if len(os.Args) > 2 { + raw = []byte(os.Args[2]) + } else { + var err error + raw, err = io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("processstatecheck: read stdin: %w", err) + } + } + var input processStateCheckInput + if err := json.Unmarshal(raw, &input); err != nil { + return fmt.Errorf("processstatecheck: decode input: %w", err) + } + output := processStateCheckOutput{ + Parse: make([]int64, len(input.Parse)), + Match: make([]bool, len(input.Match)), + } + for index, value := range input.Parse { + output.Parse[index] = ocxcli.ParsePIDFile(value) + } + for index, value := range input.Match { + output.Match[index] = ocxcli.IsOcxStartCommandLine(value) + } + encoded, err := json.Marshal(output) + if err != nil { + return fmt.Errorf("processstatecheck: encode output: %w", err) + } + fmt.Println(string(encoded)) + return nil +} diff --git a/go/cmd/ocx-sidecar/routingcheck.go b/go/cmd/ocx-sidecar/routingcheck.go new file mode 100644 index 0000000000..23ecd9eedf --- /dev/null +++ b/go/cmd/ocx-sidecar/routingcheck.go @@ -0,0 +1,28 @@ +package main + +import ( + "encoding/json" + "fmt" + "github.com/lidge-jun/opencodex/go/internal/routing/hotpath" + "os" +) + +func runRoutingCheck() error { + if len(os.Args) != 3 { + return fmt.Errorf("routingcheck requires one JSON array argument") + } + var inputs []hotpath.Input + if err := json.Unmarshal([]byte(os.Args[2]), &inputs); err != nil { + return fmt.Errorf("routingcheck: decode: %w", err) + } + out := make([]hotpath.Decision, len(inputs)) + for i, input := range inputs { + out[i] = hotpath.Decide(input) + } + raw, err := json.Marshal(out) + if err != nil { + return fmt.Errorf("routingcheck: encode: %w", err) + } + _, err = fmt.Println(string(raw)) + return err +} diff --git a/go/cmd/ocx/main.go b/go/cmd/ocx/main.go new file mode 100644 index 0000000000..83bd6404f5 --- /dev/null +++ b/go/cmd/ocx/main.go @@ -0,0 +1,19 @@ +// Command ocx is the Go CLI scaffold for the incremental runtime takeover. +package main + +import ( + "os" + + "github.com/lidge-jun/opencodex/go/internal/ocxcli" +) + +// version is set by release builds with -ldflags '-X main.version='. +var version string + +func main() { os.Exit(ocxcli.Run(os.Args[1:], ocxcli.Deps{Version: resolveVersion()})) } +func resolveVersion() string { + if version != "" { + return version + } + return "dev" +} diff --git a/go/cmd/ocx/standalone_smoke_test.go b/go/cmd/ocx/standalone_smoke_test.go new file mode 100644 index 0000000000..b282863ab5 --- /dev/null +++ b/go/cmd/ocx/standalone_smoke_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestStandaloneBinaryServesEmbeddedDashboardWithoutCheckout(t *testing.T) { + root := filepath.Clean(filepath.Join("..", "..", "..")) + binary := filepath.Join(t.TempDir(), "ocx") + build := exec.Command("go", "build", "-buildvcs=false", "-o", binary, ".") + build.Dir = "." + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, output) + } + clean := t.TempDir() + env := append(os.Environ(), "HOME="+clean, "USERPROFILE="+clean, "OPENCODEX_HOME="+filepath.Join(clean, ".opencodex"), "PATH="+t.TempDir()) + for _, args := range [][]string{{"--version"}, {"--help"}, {"codex-shim", "status"}} { + command := exec.Command(binary, args...) + command.Dir, command.Env = clean, env + if output, err := command.CombinedOutput(); err != nil || len(bytes.TrimSpace(output)) == 0 { + t.Fatalf("%s: %v %s", args, err, output) + } + } + // service status flipped to Go-owned in #53: a standalone binary now runs + // the read natively (no checkout/Bun needed) and reports the registration + // state of the isolated test HOME. The remaining TS-owned service verbs + // (install/start/stop/...) still need the TypeScript lifecycle owner, so a + // standalone binary must fail with a repairable instruction instead of + // assuming a checkout/Bun. + service := exec.Command(binary, "service", "status") + service.Dir, service.Env = clean, env + output, err := service.CombinedOutput() + if err != nil || !strings.Contains(string(output), "❌ ") || !strings.Contains(string(output), "Diagnostics: logs: ") { + t.Fatalf("service status error = %v output=%q", err, output) + } + serviceInstall := exec.Command(binary, "service", "install") + serviceInstall.Dir, serviceInstall.Env = clean, env + output, err = serviceInstall.CombinedOutput() + if err == nil || !strings.Contains(string(output), "OCX_TYPESCRIPT_CLI") { + t.Fatalf("service install error = %v output=%q", err, output) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + command := exec.CommandContext(ctx, binary, "serve-dashboard", "--listen", "127.0.0.1:0") + command.Dir, command.Env = clean, env + var stdout bytes.Buffer + command.Stdout, command.Stderr = &stdout, &stdout + if err := command.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { cancel(); _ = command.Wait() }) + deadline := time.Now().Add(5 * time.Second) + var base string + for time.Now().Before(deadline) { + for _, line := range strings.Split(stdout.String(), "\n") { + if strings.HasPrefix(line, "OpenCodex embedded dashboard listening on http://") { + base = strings.TrimPrefix(line, "OpenCodex embedded dashboard listening on ") + break + } + } + if base != "" { + break + } + time.Sleep(20 * time.Millisecond) + } + if base == "" { + t.Fatalf("server did not announce listener: %q", stdout.String()) + } + for _, path := range []string{"/healthz", "/"} { + response, err := http.Get(base + path) + if err != nil { + t.Fatalf("GET %s: %v", path, err) + } + body, _ := io.ReadAll(response.Body) + response.Body.Close() + if response.StatusCode != http.StatusOK || len(body) == 0 { + t.Fatalf("GET %s = %d %q", path, response.StatusCode, body) + } + } + _ = root +} diff --git a/go/cmd/ocx/version.go b/go/cmd/ocx/version.go new file mode 100644 index 0000000000..9e19b85bdf --- /dev/null +++ b/go/cmd/ocx/version.go @@ -0,0 +1,24 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" +) + +func packageVersionAt(dir string) (string, error) { + raw, err := os.ReadFile(filepath.Join(dir, "package.json")) + if err != nil { + return "", err + } + var manifest struct { + Version string `json:"version"` + } + if err := json.Unmarshal(raw, &manifest); err != nil || manifest.Version == "" { + if err == nil { + err = os.ErrInvalid + } + return "", err + } + return manifest.Version, nil +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000000..22a2efc1b4 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,17 @@ +module github.com/lidge-jun/opencodex/go + +go 1.24 + +require modernc.org/sqlite v1.34.5 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000000..5424fe41de --- /dev/null +++ b/go/go.sum @@ -0,0 +1,43 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/go/internal/config/config.go b/go/internal/config/config.go new file mode 100644 index 0000000000..ebb463dc82 --- /dev/null +++ b/go/internal/config/config.go @@ -0,0 +1,276 @@ +// Package config is the shared Go config reader of the incremental runtime +// takeover (ADR-0008, devlog/_plan/260905_go_sidecar_takeover, ticket #16). +// +// It reads the same on-disk config the TypeScript runtime reads +// (OPENCODEX_HOME/config.json, defaulting to ~/.opencodex/config.json) so a +// Go-served management read route can answer from the operator's real state +// instead of a snapshot invented inside the sidecar. The TypeScript side +// validates and normalises the file through its zod pipeline on load +// (src/config.ts); this package deliberately mirrors only the parts of that +// pipeline that the Go-owned read routes depend on, and it does NOT rewrite or +// move the file. Divergence is confined to configs that are invalid enough for +// TypeScript to salvage or back up, which the differential oracle never feeds. +// +// The route bodies this package feeds (today: GET /api/shadow-call-settings) +// are pure functions of the config subsection they read, so byte parity with +// the in-process TypeScript handler holds as long as both processes read the +// same file content. Numbers are decoded with json.Number so a value echoed +// into a response keeps its exact on-disk literal instead of being reformatted +// through float64, which is what byte parity requires for config-derived DTOs. +package config + +import ( + "encoding/json" + "errors" + "io" + "log" + "os" + "path/filepath" + "strings" +) + +// DefaultShadowSourceModels mirrors DEFAULT_SHADOW_SOURCE_MODELS in +// src/lib/shadow-call.ts. It is the value the shadow-call settings read route +// reports when the config carries no usable sourceModels override, and it must +// stay in lockstep with that constant — the differential oracle compares bytes. +var DefaultShadowSourceModels = []string{"gpt-5.6-luna"} + +// Dir resolves the config directory exactly like getConfigDir in +// src/config/paths.ts: OPENCODEX_HOME when set (trimmed, a leading ~ expanded), +// otherwise /.opencodex. +func Dir() (string, error) { + raw := strings.TrimSpace(os.Getenv("OPENCODEX_HOME")) + if raw == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".opencodex"), nil + } + if raw == "~" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return home, nil + } + if strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, `~\`) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, raw[2:]), nil + } + return filepath.Clean(raw), nil +} + +// Path returns the config file path (getConfigPath in src/config/paths.ts). +func Path() (string, error) { + dir, err := Dir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.json"), nil +} + +// Config is the parsed config.json. It holds exactly the subsections the +// Go-owned read routes consume; unknown top-level keys are preserved in Raw so +// a later route can project from them without a schema re-read. This is a +// foundation, not a full schema port: TS-side validation/normalisation is +// replicated only where a Go-owned route body depends on it (see package doc). +type Config struct { + // Port and Hostname are the listener defaults used by the initial Go CLI + // diagnostics. Other status projections stay TypeScript-owned until their + // own parity increments add them. + Port int + Hostname string + // ShadowCallIntercept mirrors config.shadowCallIntercept (the optional + // shadow/helper-call rewrite section). Nil when absent from the file. + ShadowCallIntercept *ShadowCallIntercept + // Raw is the whole file decoded with numbers preserved as json.Number. + Raw map[string]any +} + +// ShadowCallIntercept mirrors the shadowCallIntercept subsection of OcxConfig +// (src/types/config.ts). Values are kept as decoded JSON (not narrowed to the +// expected types) because the TypeScript runtime stores whatever the file +// carried and the read route's projection is where type coercion happens. +type ShadowCallIntercept struct { + // Enabled mirrors sci.enabled: the route reports exactly sci.enabled === true. + Enabled any `json:"enabled"` + // Model mirrors sci.model, retained as the decoded JSON value so a string + // stays a string and an absent/null key stays distinguishable. + Model any `json:"model"` + // SourceModels mirrors sci.sourceModels (decoded array or nil). + SourceModels any `json:"sourceModels"` +} + +// Load reads and decodes config.json. A missing file yields an empty Config +// (the TypeScript runtime defaults on ENOENT and getDefaultConfig carries no +// shadowCallIntercept). A malformed file yields an empty Config plus the +// decode error: the TypeScript runtime backs the file up and defaults, and the +// Go side must not move user files, so it only logs. +func Load() (*Config, error) { + path, err := Path() + if err != nil { + return nil, err + } + return LoadFromPath(path) +} + +// LoadFromDir is Load with an explicit config directory (test seam and +// supervisor-injected homes). +func LoadFromDir(dir string) (*Config, error) { + return LoadFromPath(filepath.Join(dir, "config.json")) +} + +// LoadFromPath is the raw loader; the path comes from Path() or LoadFromDir. +func LoadFromPath(path string) (*Config, error) { + file, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &Config{Raw: map[string]any{}}, nil + } + return &Config{Raw: map[string]any{}}, err + } + defer file.Close() + return decode(file) +} + +func decode(reader io.Reader) (*Config, error) { + decoder := json.NewDecoder(reader) + decoder.UseNumber() + raw := map[string]any{} + if err := decoder.Decode(&raw); err != nil { + log.Printf("ocx-sidecar: config.json is not valid JSON; treating it as empty: %v", err) + return &Config{Raw: map[string]any{}}, err + } + cfg := &Config{Raw: raw} + if port, ok := raw["port"].(json.Number); ok { + if parsed, err := port.Int64(); err == nil && parsed > 0 && parsed <= 65535 { + cfg.Port = int(parsed) + } + } + if hostname, ok := raw["hostname"].(string); ok { + cfg.Hostname = hostname + } + if section, ok := raw["shadowCallIntercept"]; ok { + if obj, ok := section.(map[string]any); ok { + cfg.ShadowCallIntercept = &ShadowCallIntercept{ + Enabled: obj["enabled"], + Model: obj["model"], + SourceModels: obj["sourceModels"], + } + } + } + return cfg, nil +} + +// ListenTarget returns normalized listener defaults for a no-runtime status +// report. The TypeScript default port is 10100. +func (c *Config) ListenTarget() (port int, hostname string) { + if c.Port > 0 { + port = c.Port + } else { + port = 10100 + } + return port, c.Hostname +} + +// ShadowCallSettings is the projection the shadow-call settings read route +// emits (src/server/management/config-routes.ts, GET /api/shadow-call-settings). +type ShadowCallSettings struct { + Enabled bool + Model any + SourceModels []string +} + +// ShadowCallSettingsView mirrors the TypeScript handler's projection: +// enabled = sci.enabled === true, model = sci.model ?? "" (so absent or null +// becomes the empty string), and sourceModels = shadowSourceModels(sci. +// sourceModels) from src/lib/shadow-call.ts — non-string entries are dropped, +// entries are trimmed, and an empty result falls back to the default list. +func (c *Config) ShadowCallSettingsView() ShadowCallSettings { + // Absent section and null model both project to the empty string (the TS + // handler's `sci.model ?? ""`), so Model starts as "" and only a present, + // non-null value replaces it. + out := ShadowCallSettings{Model: ""} + sci := c.ShadowCallIntercept + if sci == nil { + out.SourceModels = defaultSourceModels() + return out + } + out.Enabled = sci.Enabled == true + if sci.Model != nil { + out.Model = sci.Model + } + out.SourceModels = normalizeSourceModels(sci.SourceModels) + return out +} + +func normalizeSourceModels(configured any) []string { + normalized := []string{} + if array, ok := configured.([]any); ok { + for _, entry := range array { + value, ok := entry.(string) + if !ok { + continue + } + trimmed := strings.TrimSpace(value) + if trimmed != "" { + normalized = append(normalized, trimmed) + } + } + } + if len(normalized) == 0 { + return defaultSourceModels() + } + return normalized +} + +func defaultSourceModels() []string { + // Fresh slice per call: a caller must not be able to mutate the shared + // default and skew a later response. + return append([]string(nil), DefaultShadowSourceModels...) +} + +// SaveRaw atomically replaces config.json with an indented JSON representation +// of raw. It creates the config directory as needed and keeps user config +// private (0600). Validation deliberately belongs to the owning command: this +// shared reader must preserve unknown config fields. +func SaveRaw(raw map[string]any) error { + path, err := Path() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + encoded, err := json.MarshalIndent(raw, "", " ") + if err != nil { + return err + } + encoded = append(encoded, '\n') + temp, err := os.CreateTemp(filepath.Dir(path), ".config.json-*") + if err != nil { + return err + } + tempName := temp.Name() + defer os.Remove(tempName) + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return err + } + if _, err := temp.Write(encoded); err != nil { + temp.Close() + return err + } + if err := temp.Sync(); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(tempName, path) +} diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go new file mode 100644 index 0000000000..18d19ac7a2 --- /dev/null +++ b/go/internal/config/config_test.go @@ -0,0 +1,165 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFixture(t *testing.T, dir, content string) string { + t.Helper() + path := filepath.Join(dir, "config.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadMissingFileIsEmpty(t *testing.T) { + dir := t.TempDir() + cfg, err := LoadFromDir(dir) + if err != nil { + t.Fatalf("LoadFromDir on an empty dir returned an error: %v", err) + } + if cfg.ShadowCallIntercept != nil { + t.Fatalf("expected no shadowCallIntercept for a missing file, got %+v", cfg.ShadowCallIntercept) + } +} + +func TestListenTargetUsesValidatedConfigOrDefault(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, "{\"port\": 18080, \"hostname\": \"127.0.0.2\"}") + cfg, err := LoadFromDir(dir) + if err != nil { + t.Fatal(err) + } + if port, host := cfg.ListenTarget(); port != 18080 || host != "127.0.0.2" { + t.Fatalf("ListenTarget = %d, %q", port, host) + } + writeFixture(t, dir, "{\"port\": 0}") + cfg, err = LoadFromDir(dir) + if err != nil { + t.Fatal(err) + } + if port, host := cfg.ListenTarget(); port != 10100 || host != "" { + t.Fatalf("default ListenTarget = %d, %q", port, host) + } +} + +func TestLoadMalformedJSONDefaultsWithoutMovingTheFile(t *testing.T) { + dir := t.TempDir() + path := writeFixture(t, dir, "{not json") + cfg, err := LoadFromDir(dir) + if err == nil { + t.Fatal("expected a decode error for malformed JSON") + } + if cfg.ShadowCallIntercept != nil { + t.Fatalf("expected no shadowCallIntercept after a decode failure, got %+v", cfg.ShadowCallIntercept) + } + // The TS side backs the file up on an invalid parse; the Go side must never + // move or rewrite user files, so the original bytes stay untouched. + raw, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if string(raw) != "{not json" { + t.Fatalf("malformed config file was modified; got %q", raw) + } +} + +func TestShadowCallSettingsAbsentSection(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, `{"port": 18080}`) + cfg, err := LoadFromDir(dir) + if err != nil { + t.Fatal(err) + } + view := cfg.ShadowCallSettingsView() + if view.Enabled { + t.Fatal("enabled must be false when the section is absent") + } + if view.Model != "" { + t.Fatalf("model must be the empty string when the section is absent, got %#v", view.Model) + } + if got := strings.Join(view.SourceModels, ","); got != "gpt-5.6-luna" { + t.Fatalf("sourceModels = %q, want the TS default gpt-5.6-luna", got) + } +} + +func TestShadowCallSettingsProjectionMatchesTypeScript(t *testing.T) { + dir := t.TempDir() + // Includes the coercions the TS handler performs: enabled only when === true, + // model kept verbatim (spaces included, null collapses to ""), sourceModels + // filtered to non-empty trimmed strings with non-string entries dropped. + writeFixture(t, dir, `{ + "shadowCallIntercept": { + "enabled": true, + "model": " gpt-5.5 ", + "sourceModels": [" gpt-5.4-mini ", "", 42, "gpt-6-terra"] + } +}`) + cfg, err := LoadFromDir(dir) + if err != nil { + t.Fatal(err) + } + view := cfg.ShadowCallSettingsView() + if !view.Enabled { + t.Fatal("enabled must be true") + } + if view.Model != " gpt-5.5 " { + t.Fatalf("model must be echoed verbatim, got %#v", view.Model) + } + if got := strings.Join(view.SourceModels, ","); got != "gpt-5.4-mini,gpt-6-terra" { + t.Fatalf("sourceModels = %q, want gpt-5.4-mini,gpt-6-terra (42 and the empty entry dropped)", got) + } +} + +func TestShadowCallSettingsEnabledNonBooleanAndNullModel(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, `{ + "shadowCallIntercept": { "enabled": "yes", "model": null, "sourceModels": [] } +}`) + cfg, err := LoadFromDir(dir) + if err != nil { + t.Fatal(err) + } + view := cfg.ShadowCallSettingsView() + if view.Enabled { + t.Fatal(`enabled must be false for the string "yes" (sci.enabled === true)`) + } + if view.Model != "" { + t.Fatalf("null model must collapse to the empty string, got %#v", view.Model) + } + // An empty configured array falls back to the default list. + if got := strings.Join(view.SourceModels, ","); got != "gpt-5.6-luna" { + t.Fatalf("sourceModels = %q, want the default after an empty array", got) + } +} + +func TestDirHonoursOpenCodexHome(t *testing.T) { + t.Setenv("OPENCODEX_HOME", "/tmp/ocx-home-probe") + dir, err := Dir() + if err != nil { + t.Fatal(err) + } + if dir != "/tmp/ocx-home-probe" { + t.Fatalf("Dir() = %q, want the OPENCODEX_HOME value", dir) + } +} + +func TestDirFallsBackToHomeDotOpenCodex(t *testing.T) { + t.Setenv("OPENCODEX_HOME", "") + dir, err := Dir() + if err != nil { + t.Fatal(err) + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".opencodex") + if dir != want { + t.Fatalf("Dir() = %q, want %q", dir, want) + } +} diff --git a/go/internal/config/ordered.go b/go/internal/config/ordered.go new file mode 100644 index 0000000000..d98fb10ef7 --- /dev/null +++ b/go/internal/config/ordered.go @@ -0,0 +1,377 @@ +package config + +// Ordered JSON support for config echo routes (ticket #17). +// +// The TypeScript runtime stores the config it parsed and emits it again with +// JSON.stringify when a route body is the config value itself (e.g. GET +// /api/custom-models returns `config.customModels ?? []`). JSON.stringify +// preserves object key insertion order — which for a parsed config file is the +// FILE's key order — and canonicalises whitespace. encoding/json's map-based +// decoding discards key order, so a faithful echo needs a value tree that keeps +// objects ordered. That order is exactly what the /api/config DTO projection +// will also need when it is ported (provider entries keep their file order), so +// this is the shared foundation, not a one-route hack. +// +// Numbers are kept as their raw JSON literal (json.RawMessage). A config file +// written by the TypeScript runtime already contains JSON.stringify-canonical +// numbers, so raw echo equals JavaScript output; only a hand-edited file with a +// non-canonical literal (e.g. "1.0" where JavaScript would emit 1) diverges, +// which is the same class of documented non-canonical-config caveat the +// package already carries. + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "slices" + "strconv" +) + +// OrderedValue is one JSON value with object keys in document order. Only the +// operations the config echo routes need are exported: Find (key lookup), +// IsNull, and MarshalStringify (JSON.stringify-compatible bytes). +type OrderedValue struct { + kind orderedKind + obj []orderedMember // kind == orderedObject, keyed in file order + arr []*OrderedValue // kind == orderedArray + str string // kind == orderedString + num json.RawMessage // kind == orderedNumber, raw literal + b bool // kind == orderedBool +} + +type orderedKind int + +const ( + orderedNull orderedKind = iota + orderedObject + orderedArray + orderedString + orderedNumber + orderedBool +) + +type orderedMember struct { + key string + val *OrderedValue +} + +// OrderedEntry is one document-order object member. Projection routes use it +// when TypeScript's Object.entries order is part of their wire contract. +type OrderedEntry struct { + Key string + Value *OrderedValue + index uint32 +} + +// LoadOrdered reads and decodes config.json into an ordered value tree (the +// root object). A missing file yields a null root without error, mirroring +// Load's ENOENT default; a malformed file yields the decode error. +func LoadOrdered() (*OrderedValue, error) { + path, err := Path() + if err != nil { + return nil, err + } + return LoadOrderedFromPath(path) +} + +// LoadOrderedFromDir is LoadOrdered with an explicit config directory. +func LoadOrderedFromDir(dir string) (*OrderedValue, error) { + return LoadOrderedFromPath(filepath.Join(dir, "config.json")) +} + +// LoadOrderedFromPath is the raw ordered loader; the path comes from Path() or +// LoadOrderedFromDir. +func LoadOrderedFromPath(path string) (*OrderedValue, error) { + file, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &OrderedValue{kind: orderedNull}, nil + } + return nil, err + } + defer file.Close() + return decodeOrdered(file) +} + +// decodeOrdered decodes a whole JSON document into an ordered value tree. +func decodeOrdered(reader io.Reader) (*OrderedValue, error) { + decoder := json.NewDecoder(reader) + decoder.UseNumber() + value, err := decodeOrderedNext(decoder) + if err != nil { + return nil, err + } + // A second value in the stream means the file is not a single JSON + // document; the TS side would reject that too. + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return nil, errors.New("config.json contains more than one JSON value") + } + return nil, err + } + return value, nil +} + +// decodeOrderedNext recurses one JSON value from the token stream. +func decodeOrderedNext(decoder *json.Decoder) (*OrderedValue, error) { + token, err := decoder.Token() + if err != nil { + return nil, err + } + return decodeOrderedValue(decoder, token) +} + +func decodeOrderedValue(decoder *json.Decoder, token json.Token) (*OrderedValue, error) { + switch typed := token.(type) { + case nil: + return &OrderedValue{kind: orderedNull}, nil + case bool: + return &OrderedValue{kind: orderedBool, b: typed}, nil + case string: + return &OrderedValue{kind: orderedString, str: typed}, nil + case json.Number: + return &OrderedValue{kind: orderedNumber, num: json.RawMessage(typed.String())}, nil + case json.Delim: + switch typed { + case '{': + obj := &OrderedValue{kind: orderedObject} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return nil, err + } + key, ok := keyToken.(string) + if !ok { + return nil, errors.New("config.json object key is not a string") + } + member, err := decodeOrderedNext(decoder) + if err != nil { + return nil, err + } + obj.obj = append(obj.obj, orderedMember{key: key, val: member}) + } + // Consume the closing '}'. + if _, err := decoder.Token(); err != nil { + return nil, err + } + return obj, nil + case '[': + arr := &OrderedValue{kind: orderedArray} + for decoder.More() { + member, err := decodeOrderedNext(decoder) + if err != nil { + return nil, err + } + arr.arr = append(arr.arr, member) + } + // Consume the closing ']'. + if _, err := decoder.Token(); err != nil { + return nil, err + } + return arr, nil + default: + return nil, errors.New("config.json contains an unexpected delimiter") + } + default: + return nil, errors.New("config.json contains an unsupported token") + } +} + +// Find returns the member with the given key, or nil when absent. Object key +// order is preserved by decodeOrdered; callers that re-emit the value rely on +// that order being the file's. +func (v *OrderedValue) Find(key string) *OrderedValue { + if v == nil || v.kind != orderedObject { + return nil + } + for _, member := range v.obj { + if member.key == key { + return member.val + } + } + return nil +} + +// Entries returns the object's members in document order. A non-object has no +// entries. The returned slice is a copy so callers cannot mutate the tree. +func (v *OrderedValue) Entries() []OrderedEntry { + if v == nil || v.kind != orderedObject { + return nil + } + entries := make([]OrderedEntry, len(v.obj)) + for i, member := range v.obj { + entries[i] = OrderedEntry{Key: member.key, Value: member.val} + } + return entries +} + +// Elements returns an array's values in document order. A non-array has no +// elements. The returned slice is a copy. +func (v *OrderedValue) Elements() []*OrderedValue { + if v == nil || v.kind != orderedArray { + return nil + } + return append([]*OrderedValue(nil), v.arr...) +} + +// StringValue returns a JSON string's decoded value. +func (v *OrderedValue) StringValue() (string, bool) { + if v == nil || v.kind != orderedString { + return "", false + } + return v.str, true +} + +// JSONStringifyString exports the package's ECMAScript-compatible string +// escaping for projections that construct a new object around ordered values. +func JSONStringifyString(value string) ([]byte, error) { + return marshalStringJSONStringify(value) +} + +// ECMAScriptEntries returns object entries in Object.entries order: canonical +// array-index keys first by numeric value, followed by other keys in document +// order. Projection routes use it when mirroring TypeScript object iteration. +func (v *OrderedValue) ECMAScriptEntries() []OrderedEntry { + entries := v.Entries() + if len(entries) < 2 { + return entries + } + indices := make([]OrderedEntry, 0, len(entries)) + rest := make([]OrderedEntry, 0, len(entries)) + for _, entry := range entries { + if index, ok := ecmaArrayIndex(entry.Key); ok { + entry.index = index + indices = append(indices, entry) + } else { + rest = append(rest, entry) + } + } + slices.SortFunc(indices, func(a, b OrderedEntry) int { return cmp.Compare(a.index, b.index) }) + return append(indices, rest...) +} + +func ecmaArrayIndex(key string) (uint32, bool) { + if key == "0" { + return 0, true + } + if key == "" || key[0] == '0' { + return 0, false + } + value, err := strconv.ParseUint(key, 10, 32) + if err != nil || value >= 4294967295 || strconv.FormatUint(value, 10) != key { + return 0, false + } + return uint32(value), true +} + +// IsNull reports whether the value is the JSON null literal. +func (v *OrderedValue) IsNull() bool { + return v != nil && v.kind == orderedNull +} + +// MarshalStringify writes the value the way ECMAScript JSON.stringify does: +// compact, no HTML or U+2028/U+2029 escaping, object keys in document order. +// Number literals are emitted verbatim (see the package comment for why raw +// echo equals JavaScript output for TypeScript-written config files). +func (v *OrderedValue) MarshalStringify() ([]byte, error) { + var out bytes.Buffer + if err := v.marshalJSONStringify(&out); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +func (v *OrderedValue) marshalJSONStringify(out *bytes.Buffer) error { + switch v.kind { + case orderedNull: + out.WriteString("null") + case orderedBool: + if v.b { + out.WriteString("true") + } else { + out.WriteString("false") + } + case orderedString: + raw, err := marshalStringJSONStringify(v.str) + if err != nil { + return err + } + out.Write(raw) + case orderedNumber: + out.Write(v.num) + case orderedArray: + out.WriteByte('[') + for i, member := range v.arr { + if i > 0 { + out.WriteByte(',') + } + if err := member.marshalJSONStringify(out); err != nil { + return err + } + } + out.WriteByte(']') + case orderedObject: + out.WriteByte('{') + for i, member := range v.obj { + if i > 0 { + out.WriteByte(',') + } + rawKey, err := marshalStringJSONStringify(member.key) + if err != nil { + return err + } + out.Write(rawKey) + out.WriteByte(':') + if err := member.val.marshalJSONStringify(out); err != nil { + return err + } + } + out.WriteByte('}') + } + return nil +} + +// marshalStringJSONStringify encodes one string the way ECMAScript +// JSON.stringify does. encoding/json cannot be used directly: with HTML +// escaping disabled it still escapes U+2028/U+2029, while V8 emits them +// literally (verified against Bun), so the escaping is done here by hand. +// Rules pinned against JSON.stringify: quotes and backslashes are escaped, the +// five control characters get \b \t \n \f \r shortcuts, other code points +// below U+0020 become \u00xx (lowercase), and everything else — DEL, U+0080, +// U+2028/U+2029 included — is emitted literally as UTF-8. +func marshalStringJSONStringify(value string) ([]byte, error) { + var out bytes.Buffer + out.WriteByte('"') + for _, r := range value { + switch r { + case '"', '\\': + out.WriteByte('\\') + out.WriteRune(r) + case '\b': + out.WriteString(`\b`) + case '\t': + out.WriteString(`\t`) + case '\n': + out.WriteString(`\n`) + case '\f': + out.WriteString(`\f`) + case '\r': + out.WriteString(`\r`) + default: + if r < 0x20 { + hexDigits := "0123456789abcdef" + out.WriteString(`\u00`) + out.WriteByte(hexDigits[r>>4]) + out.WriteByte(hexDigits[r&0xf]) + } else { + out.WriteRune(r) + } + } + } + out.WriteByte('"') + return out.Bytes(), nil +} diff --git a/go/internal/config/ordered_test.go b/go/internal/config/ordered_test.go new file mode 100644 index 0000000000..05e0757dd0 --- /dev/null +++ b/go/internal/config/ordered_test.go @@ -0,0 +1,172 @@ +package config + +import ( + "reflect" + "strings" + "testing" +) + +// TestOrderedEchoPreservesKeyOrderAndCanonicalisesWhitespace is the byte-parity +// core: the TS handler returns JSON.stringify(config.customModels), which keeps +// each entry's FILE key order (zod passthrough does not reorder) and emits no +// whitespace. The fixture is pretty-printed with keys deliberately NOT in the +// schema's field order to prove the echo follows the file, not a struct. +func TestOrderedEchoPreservesKeyOrderAndCanonicalisesWhitespace(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, `{ + "port": 18080, + "customModels": [ + { + "zetaField": 1, + "provider": "test", + "modelId": "custom-a", + "displayName": "Custom A", + "contextWindow": 99999 + }, + { "provider": "anthropic", "modelId": "custom-b" } + ] +}`) + root, err := LoadOrderedFromDir(dir) + if err != nil { + t.Fatal(err) + } + models := root.Find("customModels") + if models == nil { + t.Fatal("customModels not found in the ordered document") + } + raw, err := models.MarshalStringify() + if err != nil { + t.Fatal(err) + } + want := `[{"zetaField":1,"provider":"test","modelId":"custom-a","displayName":"Custom A","contextWindow":99999},{"provider":"anthropic","modelId":"custom-b"}]` + if string(raw) != want { + t.Fatalf("echo = %s\nwant %s", raw, want) + } +} + +// TestOrderedEchoMissingFileYieldsNullRoot mirrors Load's ENOENT default: an +// absent config file must not error the ordered loader either. +func TestOrderedEchoMissingFileYieldsNullRoot(t *testing.T) { + root, err := LoadOrderedFromDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if !root.IsNull() { + t.Fatalf("expected a null root for a missing file, got %+v", root) + } + if found := root.Find("customModels"); found != nil { + t.Fatal("Find on a null root must return nil") + } +} + +// TestOrderedEchoNullValueIsFindable mirrors the TS `config.customModels ?? []` +// nullish coalescing: a configured null is a present value that projects to []. +func TestOrderedEchoNullValueIsFindable(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, `{"customModels": null}`) + root, err := LoadOrderedFromDir(dir) + if err != nil { + t.Fatal(err) + } + models := root.Find("customModels") + if models == nil || !models.IsNull() { + t.Fatalf("customModels must be findable and null, got %+v", models) + } +} + +// TestOrderedEchoStringEscapingMatchesJSONStringify pins the escaping contract +// against JSON.stringify: <, >, &, U+2028 and U+2029 are all emitted literally +// (no HTML escaping, no \u2028), while the five named control characters get +// shortcuts and other code points below U+0020 get lowercase \u00xx escapes. +func TestOrderedEchoStringEscapingMatchesJSONStringify(t *testing.T) { + dir := t.TempDir() + // The \u00xx and \u2028 file escapes exercise the decoder -> re-encoder + // round trip; the literal control byte 0x01 exercises direct echo. + writeFixture(t, dir, "{\"customModels\": [{\"modelId\": \"a&c\\u2028d\\u2029e\\u0001f\\t\", \"provider\": \"x\"}]}") + root, err := LoadOrderedFromDir(dir) + if err != nil { + t.Fatal(err) + } + raw, err := root.Find("customModels").MarshalStringify() + if err != nil { + t.Fatal(err) + } + if string(raw) != expectedEcho() { + t.Fatalf("escape echo = %q\nwant %q", raw, expectedEcho()) + } +} + +// expectedEcho is the exact JSON.stringify output for the fixture above, +// verified against Bun: <, >, &, U+2028 and U+2029 literal; 0x01 as \u0001; +// the tab as \t. +func expectedEcho() string { + return `[{"modelId":"a&c` + "\u2028" + `d` + "\u2029" + `e\u0001f\t","provider":"x"}]` +} + +// TestOrderedEchoNumberLiteralStaysVerbatim documents the number-literal rule: +// a TypeScript-written file already carries JSON.stringify-canonical numbers so +// raw echo equals JS output. A hand-edited non-canonical literal ("1.0") is +// echoed as-is by the Go side where JS would emit 1 — the package's documented +// non-canonical-config divergence, asserted here so the behavior is pinned. +func TestOrderedEchoNumberLiteralStaysVerbatim(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, `{"customModels": [{"modelId": "a", "contextWindow": 1.0}]}`) + root, err := LoadOrderedFromDir(dir) + if err != nil { + t.Fatal(err) + } + raw, err := root.Find("customModels").MarshalStringify() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"contextWindow":1.0`) { + t.Fatalf("non-canonical literal must be echoed verbatim, got %s", raw) + } +} + +// TestOrderedEchoNestedStructures exercises arrays of objects and nested keys, +// which is the shape a future DTO projection (provider entries) will need. +func TestOrderedEchoNestedStructures(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, `{"a": {"b": [1, true, null, {"z": 1, "y": 2}]}, "c": "tail"}`) + root, err := LoadOrderedFromDir(dir) + if err != nil { + t.Fatal(err) + } + raw, err := root.MarshalStringify() + if err != nil { + t.Fatal(err) + } + want := `{"a":{"b":[1,true,null,{"z":1,"y":2}]},"c":"tail"}` + if string(raw) != want { + t.Fatalf("nested echo = %s\nwant %s", raw, want) + } +} + +// TestOrderedEchoMalformedFileErrors mirrors LoadFromPath: the ordered loader +// must report a malformed file instead of silently echoing a partial value. +func TestOrderedEchoMalformedFileErrors(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, `{"customModels": [`) + if _, err := LoadOrderedFromDir(dir); err == nil { + t.Fatal("expected a decode error for a truncated file") + } +} + +func TestECMAScriptEntriesSortArrayIndexesOnly(t *testing.T) { + dir := t.TempDir() + writeFixture(t, dir, `{"10":true,"z":true,"2":true,"01":true,"4294967295":true,"0":true}`) + root, err := LoadOrderedFromDir(dir) + if err != nil { + t.Fatal(err) + } + entries := root.ECMAScriptEntries() + got := make([]string, len(entries)) + for i, entry := range entries { + got[i] = entry.Key + } + want := []string{"0", "2", "10", "z", "01", "4294967295"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("entries = %#v, want %#v", got, want) + } +} diff --git a/go/internal/configschema/bun_smoke_test.go b/go/internal/configschema/bun_smoke_test.go new file mode 100644 index 0000000000..984d312c14 --- /dev/null +++ b/go/internal/configschema/bun_smoke_test.go @@ -0,0 +1,42 @@ +package configschema + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestBunHolderBlocksGoCoordinator(t *testing.T) { + if _, err := exec.LookPath("bun"); err != nil { t.Skip("bun unavailable") } + dir := t.TempDir() + ready := filepath.Join(dir, "ready") + db := filepath.Join(dir, mutationDatabaseName) + script := filepath.Join(dir, "hold.ts") + const source = `import { Database } from "bun:sqlite"; +import { writeFileSync } from "node:fs"; +const [databasePath, readyPath] = Bun.argv.slice(2); +const db = new Database(databasePath, { create: true }); +db.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); +writeFileSync(readyPath, "ready"); +setTimeout(() => { db.exec("ROLLBACK"); db.close(); }, 200); +` + if err := os.WriteFile(script, []byte(source), 0o600); err != nil { t.Fatal(err) } + cmd := exec.Command("bun", script, db, ready) + if err := cmd.Start(); err != nil { t.Fatal(err) } + defer func() { _ = cmd.Wait() }() + deadline := time.Now().Add(2 * time.Second) + for { + if _, err := os.Stat(ready); err == nil { break } + if time.Now().After(deadline) { t.Fatal("Bun holder did not acquire coordinator") } + time.Sleep(10 * time.Millisecond) + } + _, err := WithMutationCoordinator(context.Background(), filepath.Join(dir, "config.json"), nil, func(int64) (bool, error) { + t.Fatal("callback must not run while Bun owns BEGIN IMMEDIATE") + return false, nil + }) + if !errors.Is(err, ErrMutationBusy) { t.Fatalf("error = %v, want busy", err) } +} diff --git a/go/internal/configschema/lock_unix.go b/go/internal/configschema/lock_unix.go new file mode 100644 index 0000000000..e9f420c035 --- /dev/null +++ b/go/internal/configschema/lock_unix.go @@ -0,0 +1,24 @@ +//go:build unix + +package configschema + +import ( + "errors" + "os" + "syscall" +) + +func tryLockPath(path string) (release func(), acquired bool, err error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, false, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { + return nil, false, nil + } + return nil, false, err + } + return func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN); _ = f.Close() }, true, nil +} diff --git a/go/internal/configschema/lock_windows.go b/go/internal/configschema/lock_windows.go new file mode 100644 index 0000000000..c6c6332f51 --- /dev/null +++ b/go/internal/configschema/lock_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package configschema + +import ( + "os" + "sync" +) + +// This keeps cross-compiled builds dependency-free. Native Windows LockFileEx +// wiring belongs to the native config command increment. +var windowsLocks sync.Map + +func tryLockPath(path string) (release func(), acquired bool, err error) { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, false, err + } + _ = f.Close() + _, loaded := windowsLocks.LoadOrStore(path, struct{}{}) + if loaded { + return nil, false, nil + } + return func() { windowsLocks.Delete(path) }, true, nil +} diff --git a/go/internal/configschema/mutation.go b/go/internal/configschema/mutation.go new file mode 100644 index 0000000000..8ffdd6543e --- /dev/null +++ b/go/internal/configschema/mutation.go @@ -0,0 +1,250 @@ +// Package configschema owns the shared config.json schema boundary and the +// SQLite coordinator used by future Go-native config mutations. +// +// Current config write commands deliberately remain TypeScript-owned. This +// package only provides the cross-language BEGIN IMMEDIATE/generation +// foundation; it must not be wired into CLI dispatch until the TypeScript write +// contract is ported in full. The library now includes display redaction, the +// account-priority pin hook, and raw-byte revalidation; full write-boundary +// schema/load-normalization coverage and command-level parity remain required +// before CLI ownership can change. +package configschema + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + _ "modernc.org/sqlite" +) + +const mutationDatabaseName = "config-mutation.sqlite" + +const createGenerationTable = "CREATE TABLE IF NOT EXISTS config_generation (singleton INTEGER PRIMARY KEY CHECK (singleton = 1), value INTEGER NOT NULL CHECK (value >= 0))" + +var ( + // ErrMutationBusy matches TypeScript's busy_timeout=0 policy: callers fail + // promptly when either runtime owns BEGIN IMMEDIATE. + ErrMutationBusy = errors.New("config mutation already in progress") + ErrGenerationConflict = errors.New("config generation conflict") + // ErrRawByteConflict is the public CLI wording used by TypeScript when a + // direct config.json writer keeps winning the bounded rebase loop. + ErrRawByteConflict = errors.New("config changed while applying this update; retry") +) + +type GenerationConflictError struct{ Current int64 } + +func (e *GenerationConflictError) Error() string { + return fmt.Sprintf("%s: current generation %d", ErrGenerationConflict, e.Current) +} +func (e *GenerationConflictError) Unwrap() error { return ErrGenerationConflict } + +type MutationResult struct { + Changed bool + Generation int64 +} + +// RawByteConflictError reports a direct writer which changed config.json while +// the SQLite coordinator was held. It unwraps to ErrRawByteConflict so a CLI +// caller can present the TypeScript-compatible retry message without matching +// error text. +type RawByteConflictError struct{ Attempts int } + +func (e *RawByteConflictError) Error() string { return ErrRawByteConflict.Error() } +func (e *RawByteConflictError) Unwrap() error { return ErrRawByteConflict } + +// ConfigMutationMaxRebaseAttempts is the bounded direct-writer retry budget +// used by TypeScript's mutatePersistedConfig. A direct writer can always race a +// final rename, so the operation fails closed after this many observed changes. +const ConfigMutationMaxRebaseAttempts = 3 + +// MutationDatabasePath is $OPENCODEX_HOME/config-mutation.sqlite, the exact +// coordinator location used by src/config.ts. +func MutationDatabasePath(configPath string) string { + return filepath.Join(filepath.Dir(configPath), mutationDatabaseName) +} + +// ReadGeneration observes an existing coordinator without creating it. Only a +// writer holding BEGIN IMMEDIATE may create the generation singleton. +func ReadGeneration(ctx context.Context, configPath string) (int64, error) { + dbPath := MutationDatabasePath(configPath) + if _, err := os.Stat(dbPath); err != nil { + return 0, err + } + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return 0, err + } + defer db.Close() + var generation int64 + if err := db.QueryRowContext(ctx, "SELECT value FROM config_generation WHERE singleton = 1").Scan(&generation); err != nil { + return 0, err + } + if generation < 0 { + return 0, errors.New("config generation singleton is invalid") + } + return generation, nil +} + +// WithMutationCoordinator runs callback inside the same SQLite transaction as +// TypeScript's withConfigMutationLockSync: busy_timeout=0, BEGIN IMMEDIATE, +// singleton initialization, and commit/rollback. callback receives the current +// generation and returns whether it published a changed config.json while the +// transaction was held. A changed result increments generation in that same +// transaction. Future callers own config-byte freshness/rebase checks. +func WithMutationCoordinator(ctx context.Context, configPath string, expected *int64, callback func(generation int64) (changed bool, err error)) (result MutationResult, retErr error) { + dir := filepath.Dir(configPath) + if err := os.MkdirAll(dir, 0o700); err != nil { + return result, err + } + dbPath := MutationDatabasePath(configPath) + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return result, err + } + defer db.Close() + _ = os.Chmod(dbPath, 0o600) + conn, err := db.Conn(ctx) + if err != nil { + return result, classifyMutationError(err) + } + defer conn.Close() + // busy_timeout is connection-local; it must be set on the exact handle that + // acquires BEGIN IMMEDIATE, not a separate database/sql pool connection. + if _, err := conn.ExecContext(ctx, "PRAGMA busy_timeout = 0"); err != nil { + return result, classifyMutationError(err) + } + if _, err := conn.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil { + return result, classifyMutationError(err) + } + open := true + defer func() { + if open { + _, _ = conn.ExecContext(context.Background(), "ROLLBACK") + } + }() + if _, err := conn.ExecContext(ctx, createGenerationTable); err != nil { + return result, err + } + if _, err := conn.ExecContext(ctx, "INSERT OR IGNORE INTO config_generation (singleton, value) VALUES (1, 0)"); err != nil { + return result, err + } + var generation int64 + if err := conn.QueryRowContext(ctx, "SELECT value FROM config_generation WHERE singleton = 1").Scan(&generation); err != nil || generation < 0 { + if err == nil { + err = errors.New("config generation singleton is invalid") + } + return result, err + } + if expected != nil && *expected != generation { + return result, &GenerationConflictError{Current: generation} + } + changed, err := callback(generation) + if err != nil { + return result, err + } + result.Generation = generation + if changed { + if _, err := conn.ExecContext(ctx, "UPDATE config_generation SET value = value + 1 WHERE singleton = 1 AND value = ?", generation); err != nil { + return result, err + } + result.Changed = true + result.Generation++ + } + if _, err := conn.ExecContext(ctx, "COMMIT"); err != nil { + return result, classifyMutationError(err) + } + open = false + return result, nil +} + +// WithRevalidatedConfigMutation is the raw-byte freshness transaction for a +// future native config set/unset/import dispatcher. It reads the authoritative +// config bytes only after BEGIN IMMEDIATE, runs mutate on a copy, then rereads +// twice before every atomic write. A non-cooperating direct writer therefore +// rebases the mutation against its latest bytes; repeated changes fail with the +// same retry message the TypeScript CLI reports. +// +// mutate must return complete replacement bytes. It may be invoked more than +// once, and must therefore be side-effect free outside the proposed config. +func WithRevalidatedConfigMutation(ctx context.Context, configPath string, expected *int64, mutate func(raw []byte, generation int64) (replacement []byte, changed bool, err error)) (MutationResult, error) { + return WithMutationCoordinator(ctx, configPath, expected, func(generation int64) (bool, error) { + base, err := os.ReadFile(configPath) + if err != nil { + return false, err + } + for attempt := 0; attempt < ConfigMutationMaxRebaseAttempts; attempt++ { + // The first decision catches a direct write that happened before or + // during mutation evaluation. + _, changed, err := mutate(bytes.Clone(base), generation) + if err != nil || !changed { + return changed, err + } + latest, err := os.ReadFile(configPath) + if err != nil { + return false, err + } + if !bytes.Equal(latest, base) { + base = latest + continue + } + // Match TypeScript's confirmed callback: even unchanged bytes are + // replayed because another authority (for example a credential + // generation) may have changed at the revalidation seam. + proposal, changed, err := mutate(bytes.Clone(latest), generation) + if err != nil || !changed { + return changed, err + } + commitBase, err := os.ReadFile(configPath) + if err != nil { + return false, err + } + if !bytes.Equal(commitBase, latest) { + base = commitBase + continue + } + if err := writeConfigBytesAtomic(configPath, proposal); err != nil { + return false, err + } + return true, nil + } + return false, &RawByteConflictError{Attempts: ConfigMutationMaxRebaseAttempts} + }) +} + +// ReplaceConfigCandidate persists an already strict-validated import through +// the shared generation coordinator. Imports intentionally replace the full +// document and do not apply the config-set account-pin hook. +func ReplaceConfigCandidate(ctx context.Context, configPath string, candidate *Normalized) (MutationResult, error) { + data, err := candidate.IndentedJSON() + if err != nil { + return MutationResult{}, err + } + data = append(data, '\n') + return WithMutationCoordinator(ctx, configPath, nil, func(int64) (bool, error) { + previous, readErr := os.ReadFile(configPath) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return false, readErr + } + if bytes.Equal(previous, data) { + return false, nil + } + if err := writeConfigBytesAtomic(configPath, data); err != nil { + return false, err + } + return true, nil + }) +} + +func classifyMutationError(err error) error { + message := strings.ToLower(err.Error()) + if strings.Contains(message, "database is locked") || strings.Contains(message, "database is busy") || strings.Contains(message, "sqlite_busy") || strings.Contains(message, "sqlite_locked") { + return fmt.Errorf("%w: %v", ErrMutationBusy, err) + } + return err +} diff --git a/go/internal/configschema/mutation_test.go b/go/internal/configschema/mutation_test.go new file mode 100644 index 0000000000..a449dbb33b --- /dev/null +++ b/go/internal/configschema/mutation_test.go @@ -0,0 +1,218 @@ +package configschema + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +func TestMutationCoordinatorInitializesAndBumpsGeneration(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + zero := int64(0) + result, err := WithMutationCoordinator(context.Background(), path, &zero, func(generation int64) (bool, error) { + if generation != 0 { + t.Fatalf("generation in callback = %d", generation) + } + return true, nil + }) + if err != nil { + t.Fatal(err) + } + if !result.Changed || result.Generation != 1 { + t.Fatalf("result = %+v", result) + } + if got, err := ReadGeneration(context.Background(), path); err != nil || got != 1 { + t.Fatalf("generation = %d, %v", got, err) + } +} + +func TestMutationCoordinatorFailsImmediatelyWhileAnotherWriterHoldsImmediate(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + db, err := sql.Open("sqlite", MutationDatabasePath(path)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec("BEGIN IMMEDIATE"); err != nil { + t.Fatal(err) + } + defer db.Exec("ROLLBACK") + _, err = WithMutationCoordinator(context.Background(), path, nil, func(int64) (bool, error) { + t.Fatal("callback must not run while busy") + return false, nil + }) + if !errors.Is(err, ErrMutationBusy) { + t.Fatalf("error = %v, want busy", err) + } +} + +func TestMutationCoordinatorRejectsStaleGenerationAndRollsBackCallback(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + stale := int64(9) + _, err := WithMutationCoordinator(context.Background(), path, &stale, func(int64) (bool, error) { + t.Fatal("callback must not run for stale generation") + return false, nil + }) + var conflict *GenerationConflictError + if !errors.As(err, &conflict) || conflict.Current != 0 { + t.Fatalf("error = %v, conflict = %+v", err, conflict) + } + _, err = WithMutationCoordinator(context.Background(), path, nil, func(int64) (bool, error) { + return false, errors.New("abort") + }) + if err == nil { + t.Fatal("callback error was swallowed") + } + // The first transaction's table creation was rolled back, so a later + // acquisition must recreate a clean singleton at zero. + result, err := WithMutationCoordinator(context.Background(), path, nil, func(generation int64) (bool, error) { + if generation != 0 { + t.Fatalf("generation after rollback = %d", generation) + } + return false, nil + }) + if err != nil || result.Changed || result.Generation != 0 { + t.Fatalf("post-rollback result = %+v, %v", result, err) + } +} + +func TestMutationCoordinatorCrashRecoveryReleasesImmediate(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + db, err := sql.Open("sqlite", MutationDatabasePath(path)) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec("BEGIN IMMEDIATE"); err != nil { + t.Fatal(err) + } + // Closing an uncommitted connection models process exit: SQLite releases + // BEGIN IMMEDIATE without a stale-owner cleanup protocol. + if err := db.Close(); err != nil { + t.Fatal(err) + } + result, err := WithMutationCoordinator(context.Background(), path, nil, func(int64) (bool, error) { return true, nil }) + if err != nil || !result.Changed || result.Generation != 1 { + t.Fatalf("result=%+v err=%v", result, err) + } +} + +func TestReplaceConfigCandidateUsesGenerationCoordinator(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + candidate, err := ValidateCandidateJSON([]byte(`{"providers":{"x":{"adapter":"openai-chat","baseUrl":"https://x.test"}},"defaultProvider":"x"}`)) + if err != nil { + t.Fatal(err) + } + result, err := ReplaceConfigCandidate(context.Background(), path, candidate) + if err != nil || !result.Changed || result.Generation != 1 { + t.Fatalf("result=%+v err=%v", result, err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(got, []byte(`"defaultProvider": "x"`)) || got[len(got)-1] != '\n' { + t.Fatalf("imported config=%q", got) + } + result, err = ReplaceConfigCandidate(context.Background(), path, candidate) + if err != nil || result.Changed || result.Generation != 1 { + t.Fatalf("unchanged result=%+v err=%v", result, err) + } +} + +func TestRevalidatedMutationRebasesAfterDirectWriterChangesBytes(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"revision":1}`), 0o600); err != nil { + t.Fatal(err) + } + calls := 0 + result, err := WithRevalidatedConfigMutation(context.Background(), path, nil, func(raw []byte, _ int64) ([]byte, bool, error) { + calls++ + if calls == 1 { + // This bypasses config-mutation.sqlite like a direct editor. The Go + // transaction must discard its stale proposal and rerun on these bytes. + if err := os.WriteFile(path, []byte(`{"revision":2,"external":true}`), 0o600); err != nil { + return nil, false, err + } + } + return append(raw[:len(raw)-1], []byte(`,"native":true}`)...), true, nil + }) + if err != nil { + t.Fatal(err) + } + if !result.Changed || result.Generation != 1 || calls != 3 { + t.Fatalf("result=%+v calls=%d", result, calls) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := []byte(`{"revision":2,"external":true,"native":true}`) + if !bytes.Equal(got, want) { + t.Fatalf("rebase lost direct writer bytes: got %s want %s", got, want) + } +} + +func TestRevalidatedMutationChecksBytesAfterConfirmedReplay(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"revision":1}`), 0o600); err != nil { + t.Fatal(err) + } + calls := 0 + _, err := WithRevalidatedConfigMutation(context.Background(), path, nil, func(raw []byte, _ int64) ([]byte, bool, error) { + calls++ + if calls == 2 { + // The first read-back was equal. Alter bytes in the confirmed replay + // so only the final pre-write read can prevent the stale overwrite. + if err := os.WriteFile(path, []byte(`{"revision":2,"external":true}`), 0o600); err != nil { + return nil, false, err + } + } + return append(raw[:len(raw)-1], []byte(`,"native":true}`)...), true, nil + }) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := []byte(`{"revision":2,"external":true,"native":true}`) + if !bytes.Equal(got, want) || calls != 4 { + t.Fatalf("final revalidation failed: got=%s calls=%d", got, calls) + } +} + +func TestRevalidatedMutationFailsClosedAfterRepeatedDirectWrites(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"revision":0}`), 0o600); err != nil { + t.Fatal(err) + } + calls := 0 + _, err := WithRevalidatedConfigMutation(context.Background(), path, nil, func(raw []byte, _ int64) ([]byte, bool, error) { + calls++ + if err := os.WriteFile(path, []byte(fmt.Sprintf(`{"revision":%d}`, calls)), 0o600); err != nil { + return nil, false, err + } + return append(raw[:len(raw)-1], []byte(`,"native":true}`)...), true, nil + }) + if !errors.Is(err, ErrRawByteConflict) || err.Error() != "config changed while applying this update; retry" { + t.Fatalf("error=%v, want TypeScript retry conflict", err) + } + if calls != ConfigMutationMaxRebaseAttempts { + t.Fatalf("calls=%d, want %d", calls, ConfigMutationMaxRebaseAttempts) + } + got, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if bytes.Contains(got, []byte("native")) { + t.Fatalf("conflicted proposal reached disk: %s", got) + } +} diff --git a/go/internal/configschema/persistence.go b/go/internal/configschema/persistence.go new file mode 100644 index 0000000000..f5b7c4f456 --- /dev/null +++ b/go/internal/configschema/persistence.go @@ -0,0 +1,86 @@ +package configschema + +import ( + "context" + "os" + "path/filepath" + "time" +) + +// WithPathLock serializes cooperative Go config writers using an OS advisory +// lock held on a stable sidecar beside config.json. Keeping the sidecar stable +// is essential: deleting it after release would let two writers lock different +// inodes. A crashed process releases its OS lock automatically. +// +// TypeScript currently coordinates config mutations through BEGIN IMMEDIATE on +// config-mutation.sqlite. This preliminary package deliberately does not claim +// to join that transaction: wiring a SQLite driver and the generation protocol +// belongs with the native config command that will consume this package. +func WithPathLock(ctx context.Context, path string, fn func() error) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + lock := path + ".lock" + for { + release, acquired, err := tryLockPath(lock) + if err != nil { + return err + } + if acquired { + defer release() + return fn() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(5 * time.Millisecond): + } + } +} + +// WriteAtomicLocked is the persistence transaction used by a future Go-native +// config dispatcher: serialize in canonical order, fsync a 0600 temp file, and +// publish it through rename while holding the shared lock. +func WriteAtomicLocked(ctx context.Context, path string, config *Normalized) error { + return WithPathLock(ctx, path, func() error { + data, err := config.IndentedJSON() + if err != nil { + return err + } + data = append(data, '\n') + return writeConfigBytesAtomic(path, data) + }) +} + +// writeConfigBytesAtomic publishes already-serialized JSON using the same +// 0600 temp/fsync/rename protocol as WriteAtomicLocked. The SQLite revalidated +// transaction owns cross-runtime serialization when this helper is called from +// WithRevalidatedConfigMutation. +func writeConfigBytesAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".config.json-*") + if err != nil { + return err + } + name := tmp.Name() + defer os.Remove(name) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(name, path); err != nil { + return err + } + return os.Chmod(path, 0o600) +} diff --git a/go/internal/configschema/persistence_test.go b/go/internal/configschema/persistence_test.go new file mode 100644 index 0000000000..04084d0d0d --- /dev/null +++ b/go/internal/configschema/persistence_test.go @@ -0,0 +1,63 @@ +package configschema + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestWriteAtomicLockedPersistsOrderedJSONWithPrivateMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.json") + normalized, err := NormalizeJSON([]byte(`{"providers":{}}`)) + if err != nil { + t.Fatal(err) + } + if err := WriteAtomicLocked(context.Background(), path, normalized); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got)[:15] != "{\n \"port\": 101" { + t.Fatalf("unexpected persisted content: %s", got) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %o, want 0600", info.Mode().Perm()) + } + lock, err := os.Stat(path + ".lock") + if err != nil { + t.Fatalf("stable lock sidecar missing: %v", err) + } + if lock.Mode().Perm() != 0o600 { + t.Fatalf("lock mode = %o, want 0600", lock.Mode().Perm()) + } +} + +func TestWithPathLockHonorsContextWhileAnotherWriterOwnsLock(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + release := make(chan struct{}) + entered := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- WithPathLock(context.Background(), path, func() error { close(entered); <-release; return nil }) + }() + <-entered + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + err := WithPathLock(ctx, path, func() error { return nil }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("error = %v, want deadline exceeded", err) + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } +} diff --git a/go/internal/configschema/schema.go b/go/internal/configschema/schema.go new file mode 100644 index 0000000000..c3ed450d13 --- /dev/null +++ b/go/internal/configschema/schema.go @@ -0,0 +1,1209 @@ +// Package configschema ports the config.json boundary shared by the TypeScript +// config command. It intentionally keeps JSON object order: config show and +// config export expose the Zod schema's default-injection order. +package configschema + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + defaultPort = int64(10100) + defaultUsageMaxReadBytes = int64(64 * 1024 * 1024) + defaultAppOwnedMemoryBudgetMB = int64(256) + maxAppOwnedMemoryBudgetMB = int64(4096) +) + +// Normalized is a config document whose object order has been projected onto +// TypeScript's configSchema order. Unknown fields remain present after known +// schema fields, matching Zod's passthrough object result. +type Normalized struct{ root *value } + +func NormalizeJSON(raw []byte) (*Normalized, error) { + v, err := parse(raw) + if err != nil { + return nil, err + } + if v.kind != objectKind { + return nil, errors.New("config must be a JSON object") + } + return &Normalized{root: normalizeLoad(v)}, nil +} + +// ValidateCandidateJSON implements the strict write boundary. Loading can +// degrade selected optional fields; writes never silently accept an invalid +// config candidate. Error wording follows Zod 4 as used in src/config.ts. +func ValidateCandidateJSON(raw []byte) (*Normalized, error) { + v, err := parse(raw) + if err != nil { + return nil, fmt.Errorf("invalid JSON: %w", err) + } + if v.kind != objectKind { + return nil, errors.New("schema_invalid: Invalid input: expected object, received array") + } + if err := validateStrictWriteFields(v); err != nil { + return nil, err + } + if err := validateTop(v); err != nil { + return nil, err + } + normalizeStrictWriteOutput(v) + return &Normalized{root: normalizeLoad(v)}, nil +} + +// validateStrictWriteFields covers fields which deliberately degrade on the +// read path but must refuse a live config set/import instead of silently +// deleting or replacing operator intent. +func validateStrictWriteFields(v *value) error { + if x := v.find("hostname"); x != nil && (x.kind != stringKind || strings.TrimSpace(x.text) == "") { + return errors.New("schema_invalid: hostname: must be a nonblank bind address") + } + if x := v.find("appOwnedMemoryBudgetMb"); x != nil && !validIntRange(x, 64, maxAppOwnedMemoryBudgetMB) { + return fmt.Errorf("schema_invalid: appOwnedMemoryBudgetMb: must be an integer from 64 to %d", maxAppOwnedMemoryBudgetMB) + } + if x := v.find("upstreamHostCircuitThreshold"); x != nil && !validIntRange(x, 0, 100) { + return errors.New("schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to 100") + } + if x := v.find("googleAntigravityStaticCatalogVersion"); x != nil && !(x.kind == numberKind && (x.number.String() == "1" || x.number.String() == "2")) { + return errors.New("schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted") + } + if x := v.find("activeCodexAccountPinned"); x != nil && (x.kind != stringKind || !regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`).MatchString(x.text)) { + return errors.New("schema_invalid: activeCodexAccountPinned: must be an account id") + } + if x := v.find("codexAccountPickerEnabled"); x != nil && x.kind != boolKind { + return errors.New("schema_invalid: codexAccountPickerEnabled: Invalid input: expected boolean, received " + zodType(x)) + } + if x := v.find("visionSidecar"); x != nil && x.kind == objectKind { + if r := x.find("reasoning"); r != nil && (r.kind != stringKind || !map[string]bool{"low": true, "medium": true, "high": true, "xhigh": true, "max": true}[r.text]) { + return errors.New("schema_invalid: visionSidecar.reasoning: must be one of low, medium, high, xhigh, max") + } + } + if err := validateAgentTaskRecovery(v); err != nil { + return err + } + if err := validateCodexAccountMaps(v); err != nil { + return err + } + if err := validateRuntimeAndRemote(v); err != nil { + return err + } + if err := validateLoopbackAndIngress(v); err != nil { + return err + } + return nil +} + +func validateAgentTaskRecovery(root *value) error { + x := root.find("agentTaskRecovery") + if x == nil { + return nil + } + if x.kind != objectKind { + return errors.New("schema_invalid: agentTaskRecovery: Invalid input: expected object, received " + zodType(x)) + } + for _, m := range x.object { + if m.key != "enabled" && m.key != "model" && m.key != "timeoutMs" && m.key != "cacheEntries" { + return fmt.Errorf("schema_invalid: agentTaskRecovery: Unrecognized key: %q", m.key) + } + } + if y := x.find("enabled"); y != nil && y.kind != boolKind { + return errors.New("schema_invalid: agentTaskRecovery.enabled: Invalid input: expected boolean, received " + zodType(y)) + } + if y := x.find("model"); y != nil { + if y.kind != stringKind { + return errors.New("schema_invalid: agentTaskRecovery.model: Invalid input: expected string, received " + zodType(y)) + } + if strings.TrimSpace(y.text) == "" { + return errors.New("schema_invalid: agentTaskRecovery.model: Too small: expected string to have >=1 characters") + } + } + if y := x.find("timeoutMs"); y != nil { + if y.kind != numberKind { + return errors.New("schema_invalid: agentTaskRecovery.timeoutMs: Invalid input: expected number, received " + zodType(y)) + } + if !validInteger(y) { + return errors.New("schema_invalid: agentTaskRecovery.timeoutMs: Invalid input: expected int, received number") + } + if !validIntRange(y, 1000, 120000) { + if integerBelow(y, 1000) { + return errors.New("schema_invalid: agentTaskRecovery.timeoutMs: Too small: expected number to be >=1000") + } + return errors.New("schema_invalid: agentTaskRecovery.timeoutMs: Too big: expected number to be <=120000") + } + } + if y := x.find("cacheEntries"); y != nil { + if y.kind != numberKind { + return errors.New("schema_invalid: agentTaskRecovery.cacheEntries: Invalid input: expected number, received " + zodType(y)) + } + if !validInteger(y) { + return errors.New("schema_invalid: agentTaskRecovery.cacheEntries: Invalid input: expected int, received number") + } + if !validIntRange(y, 1, 512) { + if integerBelow(y, 1) { + return errors.New("schema_invalid: agentTaskRecovery.cacheEntries: Too small: expected number to be >=1") + } + return errors.New("schema_invalid: agentTaskRecovery.cacheEntries: Too big: expected number to be <=512") + } + } + return nil +} + +func validateCodexAccountMaps(root *value) error { + if x := root.find("codexAccountPriorities"); x != nil { + if x.kind != objectKind { + return errors.New("schema_invalid: codexAccountPriorities.config: codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers") + } + for _, m := range x.object { + if !validPriorityKey(m.key) { + return fmt.Errorf("schema_invalid: codexAccountPriorities.%s: selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys", m.key) + } + if !validIntRange(m.value, -100, 100) { + return fmt.Errorf("schema_invalid: codexAccountPriorities.%s: selection order must be an integer between -100 and 100", m.key) + } + } + } + if x := root.find("activeCodexAccountPinned"); x != nil && (x.kind != stringKind || !regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`).MatchString(x.text)) { + return errors.New("schema_invalid: activeCodexAccountPinned: must be an account id") + } + if x := root.find("codexAccountNamespaces"); x != nil { + if x.kind != objectKind { + return errors.New("schema_invalid: codexAccountNamespaces: codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids") + } + providers := root.find("providers") + configuredAccountIDs := configuredPoolAccountIDs(root.find("codexAccounts")) + for _, m := range x.object { + if !validProviderName(m.key) { + return fmt.Errorf("schema_invalid: codexAccountNamespaces.%s: account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys", m.key) + } + if m.value.kind != stringKind || (m.value.text != "@main" && !validAccountID(m.value.text)) { + return fmt.Errorf("schema_invalid: codexAccountNamespaces.%s: account selector targets must be @main or valid Codex pool-account ids", m.key) + } + if strings.EqualFold(m.key, "combo") || strings.EqualFold(m.key, "openai") || strings.EqualFold(m.key, "policy") || (providers != nil && providers.kind == objectKind && hasFold(providers, m.key)) { + return fmt.Errorf("schema_invalid: codexAccountNamespaces.%s: account selectors must not collide with configured provider, combo, or routing policy namespaces", m.key) + } + if m.value.text != "@main" && (configuredAccountIDs[m.key] || hasKey(x, m.value.text)) { + return fmt.Errorf("schema_invalid: codexAccountNamespaces.%s: account selectors must not collide with configured Codex pool-account ids or account selector targets", m.key) + } + } + } + return nil +} + +func validateRuntimeAndRemote(root *value) error { + role := root.find("runtimeRole") + if role != nil && (role.kind != stringKind || (role.text != "standalone" && role.text != "hub" && role.text != "client")) { + return errors.New("schema_invalid: runtimeRole: must be one of \"standalone\", \"hub\", or \"client\"") + } + if err := validateHub(root.find("hub")); err != nil { + return err + } + if err := validateRemoteGUI(root.find("remoteGui")); err != nil { + return err + } + if err := validateClient(root.find("client")); err != nil { + return err + } + hasClient := root.find("client") != nil + if role != nil && role.kind == stringKind && role.text == "client" && !hasClient { + return errors.New("schema_invalid: runtimeRole client requires a complete client connection") + } + if hasClient && (role == nil || role.kind != stringKind || role.text != "client") { + return errors.New("schema_invalid: client connection requires runtimeRole client") + } + return nil +} + +func validateHub(x *value) error { + if x == nil { + return nil + } + if x.kind != objectKind { + return errors.New("schema_invalid: hub: Invalid input: expected object, received " + zodType(x)) + } + for _, m := range x.object { + if m.key != "managementPublicOrigin" && m.key != "managementIngress" { + return fmt.Errorf("schema_invalid: hub: Unrecognized key: %q", m.key) + } + } + if y := x.find("managementPublicOrigin"); y != nil && (y.kind != stringKind || !canonicalHTTPOrigin(y.text)) { + return errors.New("schema_invalid: hub.managementPublicOrigin: must be a canonical http(s) origin without credentials, path, query, or fragment") + } + return nil +} + +func validateRemoteGUI(x *value) error { + if x == nil { + return nil + } + if x.kind != objectKind { + return errors.New("schema_invalid: remoteGui: Invalid input: expected object, received " + zodType(x)) + } + for _, m := range x.object { + if m.key != "allowedTailscaleUsers" && m.key != "allowInsecureHttp" { + return fmt.Errorf("schema_invalid: remoteGui: Unrecognized key: %q", m.key) + } + } + if y := x.find("allowInsecureHttp"); y != nil && y.kind != boolKind { + return errors.New("schema_invalid: remoteGui.allowInsecureHttp: Invalid input: expected boolean, received " + zodType(y)) + } + users := x.find("allowedTailscaleUsers") + if users == nil { + return nil + } + if users.kind != arrayKind { + return errors.New("schema_invalid: remoteGui.allowedTailscaleUsers: Invalid input: expected array, received " + zodType(users)) + } + if len(users.array) > 64 { + return errors.New("schema_invalid: remoteGui.allowedTailscaleUsers: Too big: expected array to have <=64 items") + } + seen := map[string]bool{} + for i, user := range users.array { + if user.kind != stringKind { + return fmt.Errorf("schema_invalid: remoteGui.allowedTailscaleUsers.%d: Invalid input: expected string, received %s", i, zodType(user)) + } + trimmed := strings.TrimSpace(user.text) + if trimmed == "" { + return fmt.Errorf("schema_invalid: remoteGui.allowedTailscaleUsers.%d: Too small: expected string to have >=1 characters", i) + } + if len([]byte(trimmed)) > 320 { + return fmt.Errorf("schema_invalid: remoteGui.allowedTailscaleUsers.%d: must be at most 320 UTF-8 bytes", i) + } + if strings.IndexFunc(trimmed, func(r rune) bool { return r < 32 || r == 127 }) >= 0 { + return fmt.Errorf("schema_invalid: remoteGui.allowedTailscaleUsers.%d: must not contain ASCII control characters", i) + } + if seen[trimmed] { + return fmt.Errorf("schema_invalid: remoteGui.allowedTailscaleUsers.%d: must contain unique users after trimming", i) + } + seen[trimmed] = true + } + return nil +} + +func validateClient(x *value) error { + if x == nil { + return nil + } + if x.kind != objectKind { + return errors.New("schema_invalid: client: Invalid input: expected object, received " + zodType(x)) + } + allowed := map[string]bool{"serverUrl": true, "managementUrl": true, "managementTransport": true, "selectedClients": true, "tokenEnv": true, "apiKeyId": true, "tokenFingerprint": true, "protocolVersion": true, "connectedAt": true, "catalogFingerprint": true, "priorCatalog": true, "catalogSyncedAt": true, "pendingOperation": true} + for _, m := range x.object { + if !allowed[m.key] { + return fmt.Errorf("schema_invalid: client: Unrecognized key: %q", m.key) + } + } + for _, field := range []string{"serverUrl", "managementUrl", "managementTransport", "selectedClients", "tokenEnv", "apiKeyId", "tokenFingerprint", "protocolVersion", "connectedAt"} { + if x.find(field) == nil { + return fmt.Errorf("schema_invalid: client.%s: Invalid input: expected %s, received undefined", field, clientExpectedType(field)) + } + } + for _, field := range []string{"serverUrl", "managementUrl"} { + y := x.find(field) + if y.kind != stringKind || !canonicalHTTPOrigin(y.text) { + return fmt.Errorf("schema_invalid: client.%s: must be a canonical http(s) origin without credentials, path, query, or fragment", field) + } + } + if y := x.find("managementTransport"); y.kind != stringKind || (y.text != "direct" && y.text != "relay") { + return errors.New("schema_invalid: client.managementTransport: Invalid option: expected one of \"direct\"|\"relay\"") + } + y := x.find("selectedClients") + if y.kind != arrayKind || len(y.array) < 1 || len(y.array) > 2 { + return errors.New("schema_invalid: client.selectedClients: Invalid input") + } + selected := map[string]bool{} + for _, c := range y.array { + if c.kind != stringKind || (c.text != "codex" && c.text != "claude") { + return errors.New("schema_invalid: client.selectedClients: Invalid option") + } + if selected[c.text] { + return errors.New("schema_invalid: client.selectedClients: must contain unique client ids") + } + selected[c.text] = true + } + if y := x.find("tokenEnv"); y.kind != stringKind || y.text != "OPENCODEX_API_AUTH_TOKEN" { + return errors.New("schema_invalid: client.tokenEnv: Invalid input: expected \"OPENCODEX_API_AUTH_TOKEN\"") + } + if y := x.find("apiKeyId"); y.kind != stringKind || strings.TrimSpace(y.text) == "" || len(y.text) > 256 { + return errors.New("schema_invalid: client.apiKeyId: Invalid input") + } + if y := x.find("tokenFingerprint"); y.kind != stringKind || !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(y.text) { + return errors.New("schema_invalid: client.tokenFingerprint: Invalid string: must match pattern /^[a-f0-9]{64}$/") + } + if y := x.find("protocolVersion"); !validIntRange(y, 1, 1) { + return errors.New("schema_invalid: client.protocolVersion: Invalid input: expected 1") + } + if y := x.find("connectedAt"); y.kind != stringKind || !validTimestamp(y.text) { + return errors.New("schema_invalid: client.connectedAt: Invalid ISO datetime") + } + if y := x.find("catalogFingerprint"); y != nil && (y.kind != stringKind || len(y.text) < 1 || len(y.text) > 512) { + return errors.New("schema_invalid: client.catalogFingerprint: Invalid input") + } + if y := x.find("priorCatalog"); y != nil && (y.kind != stringKind || len(y.text) > 64*1024*1024) { + return errors.New("schema_invalid: client.priorCatalog: Invalid input") + } + if y := x.find("catalogSyncedAt"); y != nil && (y.kind != stringKind || !validTimestamp(y.text)) { + return errors.New("schema_invalid: client.catalogSyncedAt: Invalid ISO datetime") + } + if y := x.find("pendingOperation"); y != nil { + if err := validatePendingOperation(y); err != nil { + return err + } + } + return nil +} + +func validatePendingOperation(x *value) error { + if x.kind != objectKind { + return errors.New("schema_invalid: client.pendingOperation: Invalid input: expected object, received " + zodType(x)) + } + for _, m := range x.object { + if m.key != "kind" && m.key != "rotationId" && m.key != "newKeyIssuedAt" && m.key != "oldKeyBackupPath" { + return fmt.Errorf("schema_invalid: client.pendingOperation: Unrecognized key: %q", m.key) + } + } + if y := x.find("kind"); y == nil || y.kind != stringKind || y.text != "rotate" { + return errors.New("schema_invalid: client.pendingOperation.kind: Invalid input: expected \"rotate\"") + } + if y := x.find("rotationId"); y == nil || y.kind != stringKind || strings.TrimSpace(y.text) == "" || len(y.text) > 256 { + return errors.New("schema_invalid: client.pendingOperation.rotationId: Invalid input") + } + if y := x.find("newKeyIssuedAt"); y == nil || y.kind != stringKind || !validTimestamp(y.text) { + return errors.New("schema_invalid: client.pendingOperation.newKeyIssuedAt: Invalid ISO datetime") + } + if y := x.find("oldKeyBackupPath"); y == nil || y.kind != stringKind || y.text == "" { + return errors.New("schema_invalid: client.pendingOperation.oldKeyBackupPath: Invalid input") + } else if y.text != filepath.Join(configDir(), "service-api-token.prev") { + return fmt.Errorf("schema_invalid: client.pendingOperation.oldKeyBackupPath: must equal %s", filepath.Join(configDir(), "service-api-token.prev")) + } + return nil +} + +func validateLoopbackAndIngress(root *value) error { + if x := root.find("unauthenticatedLoopbackListener"); x != nil { + if x.kind != objectKind { + return errors.New("schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted") + } + enabled := x.find("enabled") + if enabled == nil || enabled.kind != boolKind { + return errors.New("schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean") + } + if !enabled.b { + for _, m := range x.object { + if m.key != "enabled" { + return fmt.Errorf("schema_invalid: unauthenticatedLoopbackListener: Unrecognized key: %q", m.key) + } + } + } else { + for _, m := range x.object { + if m.key != "enabled" && m.key != "port" { + return fmt.Errorf("schema_invalid: unauthenticatedLoopbackListener: Unrecognized key: %q", m.key) + } + } + if err := validPortObject(x, "unauthenticatedLoopbackListener"); err != nil { + return err + } + if p := root.find("port"); p != nil && validIntRange(p, 0, 65535) && x.find("port").number.String() == p.number.String() { + return errors.New("schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port") + } + } + } + hub := root.find("hub") + if hub == nil || hub.kind != objectKind { + return nil + } + ingress := hub.find("managementIngress") + if ingress == nil { + return nil + } + if ingress.kind != objectKind { + return errors.New("schema_invalid: hub.managementIngress: must be an object or omitted") + } + enabled := ingress.find("enabled") + if enabled == nil || enabled.kind != boolKind { + return errors.New("schema_invalid: hub.managementIngress.enabled: must be a boolean") + } + if !enabled.b { + if len(ingress.object) != 1 { + return errors.New("schema_invalid: hub.managementIngress: disabled ingress accepts only enabled") + } + return nil + } + for _, m := range ingress.object { + if m.key != "enabled" && m.key != "port" { + return errors.New("schema_invalid: hub.managementIngress: contains an unsupported field") + } + } + if err := validPortObject(ingress, "hub.managementIngress"); err != nil { + return err + } + role := root.find("runtimeRole") + if role == nil || role.kind != stringKind || role.text != "hub" { + return errors.New("schema_invalid: hub.managementIngress: enabled ingress requires runtimeRole hub") + } + ingressPort := ingress.find("port").number.String() + proxy := "10100" + if p := root.find("port"); p != nil && p.kind == numberKind { + proxy = p.number.String() + } + if ingressPort == proxy { + return errors.New("schema_invalid: hub.managementIngress.port: must differ from the proxy port") + } + if loop := root.find("unauthenticatedLoopbackListener"); loop != nil && loop.kind == objectKind { + if enabled := loop.find("enabled"); enabled != nil && enabled.kind == boolKind && enabled.b { + if p := loop.find("port"); p != nil && p.kind == numberKind && p.number.String() == ingressPort { + return errors.New("schema_invalid: hub.managementIngress.port: must differ from unauthenticatedLoopbackListener.port") + } + } + } + return nil +} + +func validPortObject(x *value, path string) error { + p := x.find("port") + if !validIntRange(p, 1, 65535) { + return fmt.Errorf("schema_invalid: %s.port: must be an integer port when enabled", path) + } + return nil +} +func validInteger(v *value) bool { + _, err := strconv.ParseInt(v.number.String(), 10, 64) + return v != nil && v.kind == numberKind && err == nil +} +func integerBelow(v *value, n int64) bool { + x, err := strconv.ParseInt(v.number.String(), 10, 64) + return err == nil && x < n +} +func validAccountID(s string) bool { + return regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`).MatchString(s) && !reservedKey(s) && s != "__main__" +} +func validPriorityKey(s string) bool { return s == "__main__" || validAccountID(s) } +func validProviderName(s string) bool { + return regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$`).MatchString(s) && !reservedKey(s) && !strings.EqualFold(s, "policy") +} +func reservedKey(s string) bool { + return strings.EqualFold(s, "__proto__") || strings.EqualFold(s, "prototype") || strings.EqualFold(s, "constructor") +} +func hasFold(v *value, key string) bool { + for _, m := range v.object { + if strings.EqualFold(m.key, key) { + return true + } + } + return false +} +func hasKey(v *value, key string) bool { return v.find(key) != nil } +func configuredPoolAccountIDs(v *value) map[string]bool { + ids := map[string]bool{} + if v == nil || v.kind != arrayKind { + return ids + } + for _, account := range v.array { + if account == nil || account.kind != objectKind { + continue + } + id, isMain := account.find("id"), account.find("isMain") + if id != nil && id.kind == stringKind && (isMain == nil || isMain.kind != boolKind || !isMain.b) { + ids[id.text] = true + } + } + return ids +} +func canonicalHTTPOrigin(s string) bool { + return canonicalOrigin(s) != "" +} + +func canonicalOrigin(s string) string { + u, err := url.Parse(s) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" { + return "" + } + host := strings.ToLower(u.Hostname()) + if host == "" { + return "" + } + port := u.Port() + if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") { + port = "" + } + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + if port != "" { + host += ":" + port + } + return strings.ToLower(u.Scheme) + "://" + host +} + +func normalizeStrictWriteOutput(root *value) { + if recovery := root.find("agentTaskRecovery"); recovery != nil && recovery.kind == objectKind { + if model := recovery.find("model"); model != nil && model.kind == stringKind { + model.text = strings.TrimSpace(model.text) + } + } + if hub := root.find("hub"); hub != nil && hub.kind == objectKind { + if origin := hub.find("managementPublicOrigin"); origin != nil && origin.kind == stringKind { + origin.text = canonicalOrigin(origin.text) + } + } + if remote := root.find("remoteGui"); remote != nil && remote.kind == objectKind { + if users := remote.find("allowedTailscaleUsers"); users != nil && users.kind == arrayKind { + for _, user := range users.array { + if user.kind == stringKind { + user.text = strings.TrimSpace(user.text) + } + } + } + } + if client := root.find("client"); client != nil && client.kind == objectKind { + for _, field := range []string{"serverUrl", "managementUrl"} { + if origin := client.find(field); origin != nil && origin.kind == stringKind { + origin.text = canonicalOrigin(origin.text) + } + } + if apiKeyID := client.find("apiKeyId"); apiKeyID != nil && apiKeyID.kind == stringKind { + apiKeyID.text = strings.TrimSpace(apiKeyID.text) + } + if operation := client.find("pendingOperation"); operation != nil && operation.kind == objectKind { + if rotationID := operation.find("rotationId"); rotationID != nil && rotationID.kind == stringKind { + rotationID.text = strings.TrimSpace(rotationID.text) + } + } + } +} +func validTimestamp(s string) bool { _, err := time.Parse(time.RFC3339, s); return err == nil } +func clientExpectedType(field string) string { + if field == "selectedClients" { + return "array" + } + if field == "protocolVersion" { + return "number" + } + return "string" +} + +func configDir() string { + if home := strings.TrimSpace(os.Getenv("OPENCODEX_HOME")); home != "" { + return home + } + home, err := os.UserHomeDir() + if err != nil { + return ".opencodex" + } + return filepath.Join(home, ".opencodex") +} + +func (n *Normalized) CompactJSON() ([]byte, error) { + if n == nil || n.root == nil { + return nil, errors.New("nil normalized config") + } + return n.root.compact(), nil +} + +func (n *Normalized) IndentedJSON() ([]byte, error) { + compact, err := n.CompactJSON() + if err != nil { + return nil, err + } + var out bytes.Buffer + if err := json.Indent(&out, compact, "", " "); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +// RedactedIndentedJSON renders a diagnostic/config-show view without exposing +// credentials. It preserves the schema-projected object order used by +// IndentedJSON so callers can retain TypeScript's observable JSON layout. +func (n *Normalized) RedactedIndentedJSON() ([]byte, error) { + if n == nil || n.root == nil { + return nil, errors.New("nil normalized config") + } + var out bytes.Buffer + redactValue(n.root, "").write(&out) + var indented bytes.Buffer + if err := json.Indent(&indented, out.Bytes(), "", " "); err != nil { + return nil, err + } + return indented.Bytes(), nil +} + +// ClearCodexAccountPinForSet applies the config-set hook shared by the +// TypeScript CLI. Restating any codexAccountPriorities path releases a stale +// manual account pin; imports deliberately do not call this hook because an +// import supplies its own complete pin state. It is intentionally a library +// operation until the native write dispatcher owns the full set contract. +func (n *Normalized) ClearCodexAccountPinForSet(path string) bool { + if n == nil || n.root == nil || n.root.kind != objectKind { + return false + } + first := strings.TrimSpace(strings.Split(path, ".")[0]) + if first != "codexAccountPriorities" { + return false + } + return n.root.delete("activeCodexAccountPinned") +} + +// ApplyConfigPathMutation performs the strict config set/unset write boundary. +// It keeps JSON object order through the private value representation, then +// projects the result through the same schema normalization used by TS writes. +func ApplyConfigPathMutation(raw []byte, path, rawValue string, remove bool) (config *Normalized, saved *Normalized, changed bool, err error) { + base, err := ValidateCandidateJSON(raw) + if err != nil { + return nil, nil, false, err + } + candidate := cloneValue(base.root) + segments, err := configPathSegments(path) + if err != nil { + return nil, nil, false, err + } + current := candidate + for _, segment := range segments[:len(segments)-1] { + next := current.find(segment) + if next == nil || next.kind != objectKind { + return nil, nil, false, fmt.Errorf("config parent path not found: %s", segment) + } + current = next + } + leaf := segments[len(segments)-1] + if remove { + if !current.delete(leaf) { + return nil, nil, false, fmt.Errorf("config path not found: %s", path) + } + } else { + parsed, parseErr := parse([]byte(rawValue)) + if parseErr != nil { + parsed = stringValue(rawValue) + } + current.set(leaf, parsed) + } + compact := candidate.compact() + config, err = ValidateCandidateJSON(compact) + if err != nil { + return nil, nil, false, err + } + if !remove { + config.ClearCodexAccountPinForSet(path) + } + if !remove { + value, found := getConfigPathValue(config.root, segments) + if !found { + return nil, nil, false, fmt.Errorf("config path not found: %s", path) + } + saved = &Normalized{root: cloneValue(value)} + } + before, _ := base.CompactJSON() + after, _ := config.CompactJSON() + return config, saved, !bytes.Equal(before, after), nil +} + +// ConfigPathValue returns a redacted JSON-ready normalized path value. +func (n *Normalized) ConfigPathValue(path string) (*Normalized, error) { + segments, err := configPathSegments(path) + if err != nil { + return nil, err + } + value, ok := getConfigPathValue(n.root, segments) + if !ok { + return nil, fmt.Errorf("config path not found: %s", path) + } + return &Normalized{root: redactValue(cloneValue(value), segments[len(segments)-1])}, nil +} + +func configPathSegments(path string) ([]string, error) { + parts := make([]string, 0) + for _, part := range strings.Split(path, ".") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if part == "__proto__" || part == "prototype" || part == "constructor" { + return nil, errors.New("invalid config path") + } + parts = append(parts, part) + } + if len(parts) == 0 { + return nil, errors.New("invalid config path") + } + return parts, nil +} +func getConfigPathValue(root *value, segments []string) (*value, bool) { + current := root + for _, segment := range segments { + if current == nil || current.kind != objectKind { + return nil, false + } + current = current.find(segment) + if current == nil { + return nil, false + } + } + return current, true +} +func cloneValue(v *value) *value { + if v == nil { + return nil + } + out := *v + if v.array != nil { + out.array = make([]*value, len(v.array)) + for i := range v.array { + out.array[i] = cloneValue(v.array[i]) + } + } + if v.object != nil { + out.object = make([]member, len(v.object)) + for i := range v.object { + out.object[i] = member{key: v.object[i].key, value: cloneValue(v.object[i].value)} + } + } + return &out +} + +type valueKind uint8 + +const ( + nullKind valueKind = iota + boolKind + numberKind + stringKind + arrayKind + objectKind +) + +type member struct { + key string + value *value +} +type value struct { + kind valueKind + b bool + number json.Number + text string + array []*value + object []member +} + +func parse(raw []byte) (*value, error) { + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + v, err := decodeValue(d) + if err != nil { + return nil, err + } + if _, err := d.Token(); err != io.EOF { + if err == nil { + return nil, errors.New("multiple JSON values") + } + return nil, err + } + return v, nil +} +func decodeValue(d *json.Decoder) (*value, error) { + t, err := d.Token() + if err != nil { + return nil, err + } + return decodeToken(d, t) +} +func decodeToken(d *json.Decoder, token json.Token) (*value, error) { + switch x := token.(type) { + case nil: + return &value{kind: nullKind}, nil + case bool: + return &value{kind: boolKind, b: x}, nil + case string: + return &value{kind: stringKind, text: x}, nil + case json.Number: + return &value{kind: numberKind, number: x}, nil + case json.Delim: + switch x { + case '{': + v := &value{kind: objectKind} + for d.More() { + key, err := d.Token() + if err != nil { + return nil, err + } + s, ok := key.(string) + if !ok { + return nil, errors.New("object key is not a string") + } + child, err := decodeValue(d) + if err != nil { + return nil, err + } + v.set(s, child) + } + _, err := d.Token() + return v, err + case '[': + v := &value{kind: arrayKind} + for d.More() { + child, err := decodeValue(d) + if err != nil { + return nil, err + } + v.array = append(v.array, child) + } + _, err := d.Token() + return v, err + } + } + return nil, errors.New("unsupported JSON token") +} +func (v *value) find(key string) *value { + if v == nil || v.kind != objectKind { + return nil + } + for _, m := range v.object { + if m.key == key { + return m.value + } + } + return nil +} +func (v *value) set(key string, x *value) { + for i := range v.object { + if v.object[i].key == key { + v.object[i].value = x + return + } + } + v.object = append(v.object, member{key, x}) +} +func (v *value) delete(key string) bool { + if v == nil || v.kind != objectKind { + return false + } + for i, m := range v.object { + if m.key == key { + v.object = append(v.object[:i], v.object[i+1:]...) + return true + } + } + return false +} +func (v *value) has(key string) bool { return v.find(key) != nil } +func number(n int64) *value { + return &value{kind: numberKind, number: json.Number(strconv.FormatInt(n, 10))} +} +func stringValue(s string) *value { return &value{kind: stringKind, text: s} } + +var schemaOrder = []string{"port", "runtimeRole", "hub", "remoteGui", "client", "managementUsageMaxReadBytes", "upstreamHostCircuitThreshold", "maxUpstreamBodyBytes", "appOwnedMemoryBudgetMb", "hostname", "unauthenticatedLoopbackListener", "providers", "defaultProvider", "defaultModelAliases", "cursorEffortRows", "configRebaseProvenance", "emptyCompletionRetry", "oauthOpenBrowser", "openaiProviderTierVersion", "googleAntigravityStaticCatalogVersion", "clientIntegrations", "providerContextCaps", "contextCapValue", "multiAgentGuidanceEnabled", "agentTaskRecovery", "injectionModel", "injectionEffort", "syncCodexSubagentDefaults", "subagentModelFallbackByModel", "codexShimAutoRestore", "codexDesktopAuthless", "pausedCodexAccountIds", "codexAccountNamespaces", "codexAccountPriorities", "activeCodexAccountPinned", "codexAccountPickerEnabled", "showCodexSparkQuota", "resetCreditAutoRedeem", "grokExcludedModels", "streamMode", "blockedModelRedirects", "experimentalRealtimeWsBaseUrl", "apiKeys"} + +func normalizeLoad(in *value) *value { + out := &value{kind: objectKind} + known := map[string]bool{} + for _, key := range schemaOrder { + known[key] = true + x := in.find(key) + switch key { + case "port": + if x == nil || !validIntRange(x, 0, 65535) { + out.set(key, number(defaultPort)) + } else { + out.set(key, x) + } + case "managementUsageMaxReadBytes": + if x == nil || !validIntRange(x, 1, math.MaxInt64) { + out.set(key, number(defaultUsageMaxReadBytes)) + } else { + out.set(key, x) + } + case "appOwnedMemoryBudgetMb": + if x == nil || !validIntRange(x, 64, maxAppOwnedMemoryBudgetMB) { + out.set(key, number(defaultAppOwnedMemoryBudgetMB)) + } else { + out.set(key, x) + } + case "defaultProvider": + if x == nil { + out.set(key, stringValue("openai")) + } else { + out.set(key, x) + } + case "hostname": + if x != nil && x.kind == stringKind && strings.TrimSpace(x.text) != "" { + out.set(key, x) + } + case "upstreamHostCircuitThreshold", "maxUpstreamBodyBytes": + if x != nil && validIntRange(x, 0, math.MaxInt64) { + out.set(key, x) + } + case "providers": + if x != nil && x.kind == objectKind { + out.set(key, normalizeProviders(x)) + } else if x != nil { + out.set(key, x) + } + default: + if x != nil { + out.set(key, x) + } + } + } + for _, m := range in.object { + if !known[m.key] { + out.set(m.key, m.value) + } + } + return out +} +func normalizeProviders(in *value) *value { + out := &value{kind: objectKind} + for _, m := range in.object { + if m.value.kind != objectKind { + out.set(m.key, m.value) + continue + } + p := &value{kind: objectKind} + if x := m.value.find("adapter"); x != nil { + p.set("adapter", x) + } + if x := m.value.find("baseUrl"); x != nil { + p.set("baseUrl", x) + } + for _, field := range m.value.object { + if field.key != "adapter" && field.key != "baseUrl" { + p.set(field.key, field.value) + } + } + out.set(m.key, p) + } + return out +} +func validIntRange(v *value, min, max int64) bool { + if v == nil || v.kind != numberKind { + return false + } + n, err := strconv.ParseInt(v.number.String(), 10, 64) + return err == nil && n >= min && n <= max +} + +func validateTop(v *value) error { + var diagnostics []string + if port := v.find("port"); port != nil { + if port.kind != numberKind { + diagnostics = append(diagnostics, "port: Invalid input: expected number, received string") + } + if port.kind == numberKind { + n, err := strconv.ParseInt(port.number.String(), 10, 64) + if err != nil { + diagnostics = append(diagnostics, "port: Invalid input: expected int, received number") + } else if n < 0 { + diagnostics = append(diagnostics, "port: Too small: expected number to be >=0") + } else if n > 65535 { + diagnostics = append(diagnostics, "port: Too big: expected number to be <=65535") + } + } + } + providers := v.find("providers") + if providers == nil { + diagnostics = append(diagnostics, "providers: Invalid input: expected record, received undefined") + } + if providers != nil { + if providers.kind != objectKind { + diagnostics = append(diagnostics, fmt.Sprintf("providers: Invalid input: expected record, received %s", zodType(providers))) + } else { + for _, p := range providers.object { + if p.value.kind != objectKind { + diagnostics = append(diagnostics, fmt.Sprintf("providers.%s: Invalid input: expected object, received %s", p.key, zodType(p.value))) + continue + } + if x := p.value.find("adapter"); x == nil { + diagnostics = append(diagnostics, fmt.Sprintf("providers.%s.adapter: Invalid input: expected string, received undefined", p.key)) + } else if x.kind != stringKind { + diagnostics = append(diagnostics, fmt.Sprintf("providers.%s.adapter: Invalid input: expected string, received %s", p.key, zodType(x))) + } else if x.text == "" { + diagnostics = append(diagnostics, fmt.Sprintf("providers.%s.adapter: Too small: expected string to have >=1 characters", p.key)) + } + if x := p.value.find("baseUrl"); x == nil { + diagnostics = append(diagnostics, fmt.Sprintf("providers.%s.baseUrl: Invalid input: expected string, received undefined", p.key)) + } else if x.kind != stringKind { + diagnostics = append(diagnostics, fmt.Sprintf("providers.%s.baseUrl: Invalid input: expected string, received %s", p.key, zodType(x))) + } else if x.text == "" { + diagnostics = append(diagnostics, fmt.Sprintf("providers.%s.baseUrl: Too small: expected string to have >=1 characters", p.key)) + } + } + } + } + if d := v.find("defaultProvider"); d != nil { + if d.kind != stringKind { + diagnostics = append(diagnostics, fmt.Sprintf("defaultProvider: Invalid input: expected string, received %s", zodType(d))) + } + if d.kind == stringKind && d.text == "" { + diagnostics = append(diagnostics, "defaultProvider: Too small: expected string to have >=1 characters") + } + } + if len(diagnostics) > 0 { + return errors.New("schema_invalid: " + strings.Join(diagnostics, "; ")) + } + return nil +} +func zodType(v *value) string { + if v == nil { + return "undefined" + } + switch v.kind { + case nullKind: + return "null" + case boolKind: + return "boolean" + case numberKind: + return "number" + case stringKind: + return "string" + case arrayKind: + return "array" + default: + return "object" + } +} +func (v *value) compact() []byte { var b bytes.Buffer; v.write(&b); return b.Bytes() } +func (v *value) write(b *bytes.Buffer) { + switch v.kind { + case nullKind: + b.WriteString("null") + case boolKind: + if v.b { + b.WriteString("true") + } else { + b.WriteString("false") + } + case numberKind: + b.WriteString(v.number.String()) + case stringKind: + raw, _ := json.Marshal(v.text) + b.Write(raw) + case arrayKind: + b.WriteByte('[') + for i, x := range v.array { + if i > 0 { + b.WriteByte(',') + } + x.write(b) + } + b.WriteByte(']') + case objectKind: + b.WriteByte('{') + for i, m := range v.object { + if i > 0 { + b.WriteByte(',') + } + raw, _ := json.Marshal(m.key) + b.Write(raw) + b.WriteByte(':') + m.value.write(b) + } + b.WriteByte('}') + } +} + +func redactValue(v *value, key string) *value { + if key == "modelCosts" { + return sanitizeModelCostsForDisplay(v) + } + if isSecretKey(key) && v.kind == stringKind && v.text != "" { + return stringValue("********") + } + switch v.kind { + case arrayKind: + out := &value{kind: arrayKind, array: make([]*value, len(v.array))} + for i, child := range v.array { + out.array[i] = redactValue(child, "") + } + return out + case objectKind: + out := &value{kind: objectKind, object: make([]member, 0, len(v.object))} + for _, child := range v.object { + redacted := redactValue(child.value, child.key) + // JSON.stringify omits an object property whose value is undefined. + // TS's sanitizeModelCostsForDisplay returns undefined when no row + // survives, so retain the same projection here. + if child.key == "modelCosts" && redacted == nil { + continue + } + out.object = append(out.object, member{key: child.key, value: redacted}) + } + return out + default: + return v + } +} + +var secretModelIDPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(?:^|[^A-Za-z0-9._-])sk-[A-Za-z0-9][A-Za-z0-9._-]{6,}(?:$|[^A-Za-z0-9._-])`), + regexp.MustCompile(`(?i)(?:^|[^A-Za-z0-9_])(gh[pousr]_[A-Za-z0-9_]{8,}|github_pat_[A-Za-z0-9_]{20,})(?:$|[^A-Za-z0-9_])`), + regexp.MustCompile(`(?i)\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret)=[^&\s"',;]+`), +} + +// sanitizeModelCostsForDisplay mirrors sanitizeModelCostsForDisplay in +// src/config.ts: project only valid four-rate tuples and drop a model ID which +// resembles a credential rather than replacing it with a colliding placeholder. +// nil models JavaScript's undefined, causing the containing modelCosts member to +// be omitted by redactValue. +func sanitizeModelCostsForDisplay(costs *value) *value { + if costs == nil || costs.kind != objectKind { + return nil + } + out := &value{kind: objectKind} + for _, row := range costs.object { + if secretShapedModelID(row.key) || row.value == nil || row.value.kind != objectKind { + continue + } + rates := make([]member, 0, 4) + valid := true + for _, field := range []string{"input", "output", "cacheRead", "cacheWrite"} { + rate := row.value.find(field) + if !validCostRate(rate) { + valid = false + break + } + rates = append(rates, member{key: field, value: rate}) + } + if valid { + out.object = append(out.object, member{key: row.key, value: &value{kind: objectKind, object: rates}}) + } + } + if len(out.object) == 0 { + return nil + } + return out +} + +func validCostRate(v *value) bool { + if v == nil || v.kind != numberKind { + return false + } + n, err := strconv.ParseFloat(v.number.String(), 64) + return err == nil && !math.IsNaN(n) && !math.IsInf(n, 0) && n >= 0 && n <= 1_000_000 +} + +func secretShapedModelID(id string) bool { + for _, pattern := range secretModelIDPatterns { + if pattern.MatchString(id) { + return true + } + } + return false +} + +func isSecretKey(key string) bool { + switch strings.ToLower(key) { + case "apikey", "key", "accesstoken", "refreshtoken", "idtoken", "token", "password", "clientsecret": + return true + } + return false +} diff --git a/go/internal/configschema/schema_test.go b/go/internal/configschema/schema_test.go new file mode 100644 index 0000000000..584f36fc86 --- /dev/null +++ b/go/internal/configschema/schema_test.go @@ -0,0 +1,320 @@ +package configschema + +import ( + "strings" + "testing" +) + +func TestNormalizeInjectsDefaultsInTypeScriptSchemaOrder(t *testing.T) { + normalized, err := NormalizeJSON([]byte(`{"providers":{"acme":{"baseUrl":"https://api.example/v1","adapter":"openai-chat"}},"unknownFuture":true}`)) + if err != nil { + t.Fatalf("NormalizeJSON: %v", err) + } + got, err := normalized.IndentedJSON() + if err != nil { + t.Fatalf("IndentedJSON: %v", err) + } + want := "{\n \"port\": 10100,\n \"managementUsageMaxReadBytes\": 67108864,\n \"appOwnedMemoryBudgetMb\": 256,\n \"providers\": {\n \"acme\": {\n \"adapter\": \"openai-chat\",\n \"baseUrl\": \"https://api.example/v1\"\n }\n },\n \"defaultProvider\": \"openai\",\n \"unknownFuture\": true\n}" + if string(got) != want { + t.Fatalf("normalized JSON mismatch\n got: %s\nwant: %s", got, want) + } +} + +func TestValidateCandidatePortErrorsMatchTypeScript(t *testing.T) { + _, err := ValidateCandidateJSON([]byte(`{"port":-1,"providers":{}}`)) + if err == nil { + t.Fatal("ValidateCandidateJSON unexpectedly succeeded") + } + const want = "schema_invalid: port: Too small: expected number to be >=0" + if err.Error() != want { + t.Fatalf("error = %q, want %q", err, want) + } +} + +func TestValidateCandidateChecksProviderMap(t *testing.T) { + cases := []struct{ name, raw, want string }{ + {"providers must be object", `{"providers":[]}`, "schema_invalid: providers: Invalid input: expected record, received array"}, + {"provider adapter required", `{"providers":{"x":{"baseUrl":"https://x"}}}`, "schema_invalid: providers.x.adapter: Invalid input: expected string, received undefined"}, + {"provider base URL required", `{"providers":{"x":{"adapter":"openai-chat"}}}`, "schema_invalid: providers.x.baseUrl: Invalid input: expected string, received undefined"}, + {"default provider nonblank", `{"providers":{},"defaultProvider":""}`, "schema_invalid: defaultProvider: Too small: expected string to have >=1 characters"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ValidateCandidateJSON([]byte(tc.raw)) + if err == nil || err.Error() != tc.want { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } +} + +func TestNormalizeDropsLoadTimeDegradedOptionals(t *testing.T) { + normalized, err := NormalizeJSON([]byte(`{"hostname":" ","appOwnedMemoryBudgetMb":-1,"upstreamHostCircuitThreshold":-1,"providers":{}}`)) + if err != nil { + t.Fatal(err) + } + compact, err := normalized.CompactJSON() + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"hostname", "upstreamHostCircuitThreshold"} { + if strings.Contains(string(compact), forbidden) { + t.Fatalf("%s survived load normalizer: %s", forbidden, compact) + } + } + if !strings.Contains(string(compact), `"appOwnedMemoryBudgetMb":256`) { + t.Fatalf("app default missing: %s", compact) + } +} + +func TestRedactedProjectionDropsSecretAndInvalidModelCostsRows(t *testing.T) { + normalized, err := NormalizeJSON([]byte(`{ + "providers": { + "example": { + "adapter": "openai-chat", + "baseUrl": "https://example.test", + "modelCosts": { + "gpt-safe": {"input": 1, "output": 2, "cacheRead": 0, "cacheWrite": 3, "ignored": "not displayed"}, + "sk-abcdef1234567890": {"input": 99, "output": 99, "cacheRead": 99, "cacheWrite": 99}, + "bad-rate": {"input": 1, "output": 2, "cacheRead": 0} + } + } + } +}`)) + if err != nil { + t.Fatal(err) + } + got, err := normalized.RedactedIndentedJSON() + if err != nil { + t.Fatal(err) + } + text := string(got) + for _, leaked := range []string{"sk-abcdef1234567890", "99", "bad-rate", "ignored"} { + if strings.Contains(text, leaked) { + t.Fatalf("redacted config leaked %q: %s", leaked, text) + } + } + if !strings.Contains(text, `"gpt-safe": {`) || !strings.Contains(text, `"cacheWrite": 3`) { + t.Fatalf("valid display tuple missing: %s", text) + } +} + +func TestRedactedProjectionOmitsEmptyModelCosts(t *testing.T) { + normalized, err := NormalizeJSON([]byte(`{"providers":{"example":{"adapter":"openai-chat","baseUrl":"https://example.test","modelCosts":{"sk-abcdef1234567890":{"input":1,"output":1,"cacheRead":1,"cacheWrite":1}}}}}`)) + if err != nil { + t.Fatal(err) + } + got, err := normalized.RedactedIndentedJSON() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(got), "modelCosts") { + t.Fatalf("empty sanitized modelCosts must be omitted: %s", got) + } +} + +func TestSetCodexAccountPrioritiesClearsManualPin(t *testing.T) { + normalized, err := NormalizeJSON([]byte(`{"providers":{},"activeCodexAccountPinned":"acct-1","codexAccountPriorities":{"acct-1":7}}`)) + if err != nil { + t.Fatal(err) + } + if !normalized.ClearCodexAccountPinForSet("codexAccountPriorities.acct-1") { + t.Fatal("priority set did not clear manual pin") + } + got, err := normalized.CompactJSON() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(got), "activeCodexAccountPinned") { + t.Fatalf("manual pin survived priority update: %s", got) + } + other, err := NormalizeJSON([]byte(`{"providers":{},"activeCodexAccountPinned":"acct-2"}`)) + if err != nil { + t.Fatal(err) + } + if other.ClearCodexAccountPinForSet("providers.example.adapter") { + t.Fatal("unrelated config set cleared a pin") + } + otherJSON, err := other.CompactJSON() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(otherJSON), "activeCodexAccountPinned") { + t.Fatalf("unrelated config set removed pin: %s", otherJSON) + } +} + +func TestStrictWriteSchemaRejectsLoadDegradedFields(t *testing.T) { + cases := []struct{ raw, want string }{ + {`{"providers":{},"hostname":" "}`, "schema_invalid: hostname: must be a nonblank bind address"}, + {`{"providers":{},"appOwnedMemoryBudgetMb":63}`, "schema_invalid: appOwnedMemoryBudgetMb: must be an integer from 64 to 4096"}, + {`{"providers":{},"googleAntigravityStaticCatalogVersion":3}`, "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted"}, + {`{"providers":{},"activeCodexAccountPinned":123}`, "schema_invalid: activeCodexAccountPinned: must be an account id"}, + } + for _, tc := range cases { + _, err := ValidateCandidateJSON([]byte(tc.raw)) + if err == nil || err.Error() != tc.want { + t.Fatalf("ValidateCandidateJSON(%s) = %v, want %q", tc.raw, err, tc.want) + } + } +} + +func TestStrictWriteSchemaUsesVisionCommandVocabulary(t *testing.T) { + for _, reasoning := range []string{"none", "minimal", "ultra"} { + _, err := ValidateCandidateJSON([]byte(`{"providers":{},"visionSidecar":{"reasoning":"` + reasoning + `"}}`)) + const want = "schema_invalid: visionSidecar.reasoning: must be one of low, medium, high, xhigh, max" + if err == nil || err.Error() != want { + t.Fatalf("reasoning %q error = %v, want %q", reasoning, err, want) + } + } +} + +func TestApplyConfigPathMutationUsesStrictSchemaAndPinHook(t *testing.T) { + raw := []byte(`{"providers":{},"activeCodexAccountPinned":"acct-1","codexAccountPriorities":{"acct-1":1}}`) + updated, saved, changed, err := ApplyConfigPathMutation(raw, "codexAccountPriorities.acct-1", "2", false) + if err != nil || !changed { + t.Fatalf("mutation = %v, changed=%t", err, changed) + } + savedJSON, _ := saved.CompactJSON() + if string(savedJSON) != "2" { + t.Fatalf("saved value = %s", savedJSON) + } + updatedJSON, _ := updated.CompactJSON() + if strings.Contains(string(updatedJSON), "activeCodexAccountPinned") { + t.Fatalf("set retained pin: %s", updatedJSON) + } + _, _, _, err = ApplyConfigPathMutation(raw, "hostname", `" "`, false) + if err == nil || err.Error() != "schema_invalid: hostname: must be a nonblank bind address" { + t.Fatalf("strict mutation error = %v", err) + } +} + +// These examples were taken from direct calls to TypeScript's +// validateConfigCandidate. Keep the write boundary strict even where the +// config loader deliberately degrades malformed optional fields. +func TestStrictWriteSchemaRuntimeAndRemoteClientBoundaries(t *testing.T) { + validClient := `{"serverUrl":"https://hub.example.test","managementUrl":"https://manage.example.test","managementTransport":"direct","selectedClients":["codex","claude"],"tokenEnv":"OPENCODEX_API_AUTH_TOKEN","apiKeyId":"issued-key-id","tokenFingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","protocolVersion":1,"connectedAt":"2026-08-28T00:00:00.000Z"}` + cases := []struct{ name, raw, want string }{ + {"bad role", `{"providers":{},"runtimeRole":"server"}`, "schema_invalid: runtimeRole: must be one of \"standalone\", \"hub\", or \"client\""}, + {"client role needs connection", `{"providers":{},"runtimeRole":"client"}`, "schema_invalid: runtimeRole client requires a complete client connection"}, + {"connection needs client role", `{"providers":{},"client":` + validClient + `}`, "schema_invalid: client connection requires runtimeRole client"}, + {"duplicate selected client", `{"providers":{},"runtimeRole":"client","client":{"serverUrl":"https://hub.example.test","managementUrl":"https://manage.example.test","managementTransport":"direct","selectedClients":["codex","codex"],"tokenEnv":"OPENCODEX_API_AUTH_TOKEN","apiKeyId":"issued-key-id","tokenFingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","protocolVersion":1,"connectedAt":"2026-08-28T00:00:00.000Z"}}`, "schema_invalid: client.selectedClients: must contain unique client ids"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ValidateCandidateJSON([]byte(tc.raw)) + if err == nil || err.Error() != tc.want { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } + if _, err := ValidateCandidateJSON([]byte(`{"providers":{},"runtimeRole":"client","client":` + validClient + `}`)); err != nil { + t.Fatalf("valid client rejected: %v", err) + } +} + +func TestStrictWriteSchemaHubAndRemoteGUIBoundaries(t *testing.T) { + cases := []struct{ name, raw, want string }{ + {"unsafe hub origin", `{"providers":{},"hub":{"managementPublicOrigin":"https://user@hub.example.test"}}`, "schema_invalid: hub.managementPublicOrigin: must be a canonical http(s) origin without credentials, path, query, or fragment"}, + {"duplicate tailscale users", `{"providers":{},"remoteGui":{"allowedTailscaleUsers":[" alice@example.test ","alice@example.test"]}}`, "schema_invalid: remoteGui.allowedTailscaleUsers.1: must contain unique users after trimming"}, + {"unknown hub property", `{"providers":{},"hub":{"unexpected":true}}`, "schema_invalid: hub: Unrecognized key: \"unexpected\""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ValidateCandidateJSON([]byte(tc.raw)) + if err == nil || err.Error() != tc.want { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } + if _, err := ValidateCandidateJSON([]byte(`{"providers":{},"hub":{"managementPublicOrigin":"https://hub.example.test:443"},"remoteGui":{"allowedTailscaleUsers":[" alice@example.test "]}}`)); err != nil { + t.Fatalf("valid hub/remote GUI rejected: %v", err) + } +} + +func TestStrictWriteSchemaCodexAccountMaps(t *testing.T) { + cases := []struct{ name, raw, want string }{ + {"priority record", `{"providers":{},"codexAccountPriorities":[]}`, "schema_invalid: codexAccountPriorities.config: codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers"}, + {"priority key", `{"providers":{},"codexAccountPriorities":{"bad id!":1}}`, "schema_invalid: codexAccountPriorities.bad id!: selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys"}, + {"priority value", `{"providers":{},"codexAccountPriorities":{"work":101}}`, "schema_invalid: codexAccountPriorities.work: selection order must be an integer between -100 and 100"}, + {"namespace record", `{"providers":{},"codexAccountNamespaces":[]}`, "schema_invalid: codexAccountNamespaces: codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids"}, + {"namespace target", `{"providers":{},"codexAccountNamespaces":{"work":"?"}}`, "schema_invalid: codexAccountNamespaces.work: account selector targets must be @main or valid Codex pool-account ids"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ValidateCandidateJSON([]byte(tc.raw)) + if err == nil || err.Error() != tc.want { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } + if _, err := ValidateCandidateJSON([]byte(`{"providers":{},"codexAccountPriorities":{"__main__":-100,"work":100},"codexAccountNamespaces":{"work":"side-acct","main":"@main"}}`)); err != nil { + t.Fatalf("valid account maps rejected: %v", err) + } +} + +func TestStrictWriteSchemaIngressAndRecoveryBoundaries(t *testing.T) { + cases := []struct{ name, raw, want string }{ + {"recovery strict property", `{"providers":{},"agentTaskRecovery":{"url":"https://attacker.example"}}`, "schema_invalid: agentTaskRecovery: Unrecognized key: \"url\""}, + {"recovery timeout", `{"providers":{},"agentTaskRecovery":{"timeoutMs":999}}`, "schema_invalid: agentTaskRecovery.timeoutMs: Too small: expected number to be >=1000"}, + {"loopback disabled shape", `{"providers":{},"unauthenticatedLoopbackListener":{"enabled":false,"port":1}}`, "schema_invalid: unauthenticatedLoopbackListener: Unrecognized key: \"port\""}, + {"loopback collision", `{"providers":{},"port":1234,"unauthenticatedLoopbackListener":{"enabled":true,"port":1234}}`, "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port"}, + {"ingress needs hub", `{"providers":{},"hub":{"managementIngress":{"enabled":true,"port":1235}}}`, "schema_invalid: hub.managementIngress: enabled ingress requires runtimeRole hub"}, + {"ingress loopback collision", `{"providers":{},"runtimeRole":"hub","unauthenticatedLoopbackListener":{"enabled":true,"port":1235},"hub":{"managementIngress":{"enabled":true,"port":1235}}}`, "schema_invalid: hub.managementIngress.port: must differ from unauthenticatedLoopbackListener.port"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ValidateCandidateJSON([]byte(tc.raw)) + if err == nil || err.Error() != tc.want { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } + if _, err := ValidateCandidateJSON([]byte(`{"providers":{},"runtimeRole":"hub","hub":{"managementIngress":{"enabled":true,"port":1235}},"agentTaskRecovery":{"enabled":true,"model":"gpt-5.6-sol","timeoutMs":1000,"cacheEntries":512}}`)); err != nil { + t.Fatalf("valid ingress/recovery rejected: %v", err) + } +} + +func TestStrictWriteSchemaNormalizesAcceptedRemoteValues(t *testing.T) { + normalized, err := ValidateCandidateJSON([]byte( + `{"providers":{},"hub":{"managementPublicOrigin":"https://HUB.example.test:443/"},"remoteGui":{"allowedTailscaleUsers":[" alice@example.test "]},"agentTaskRecovery":{"model":" gpt-5.6-sol "},"runtimeRole":"client","client":{"serverUrl":"https://HUB.example.test:443/","managementUrl":"http://manage.example.test:80/","managementTransport":"direct","selectedClients":["codex"],"tokenEnv":"OPENCODEX_API_AUTH_TOKEN","apiKeyId":" issued-key-id ","tokenFingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","protocolVersion":1,"connectedAt":"2026-08-28T00:00:00.000Z"}}`, + )) + if err != nil { + t.Fatal(err) + } + got, err := normalized.CompactJSON() + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"managementPublicOrigin":"https://hub.example.test"`, `"allowedTailscaleUsers":["alice@example.test"]`, `"model":"gpt-5.6-sol"`, `"serverUrl":"https://hub.example.test"`, `"managementUrl":"http://manage.example.test"`, `"apiKeyId":"issued-key-id"`} { + if !strings.Contains(string(got), want) { + t.Fatalf("normalized output %s does not contain %s", got, want) + } + } +} + +func TestStrictWriteSchemaAccountNamespaceCollisionsAndClientState(t *testing.T) { + t.Setenv("OPENCODEX_HOME", "/tmp/ocx37-home") + cases := []struct{ name, raw, want string }{ + {"namespace provider collision", `{"providers":{"work":{"adapter":"openai-chat","baseUrl":"https://example.test"}},"codexAccountNamespaces":{"work":"side-acct"}}`, "schema_invalid: codexAccountNamespaces.work: account selectors must not collide with configured provider, combo, or routing policy namespaces"}, + {"namespace target collision", `{"providers":{},"codexAccountNamespaces":{"first":"same-account","same-account":"@main"}}`, "schema_invalid: codexAccountNamespaces.first: account selectors must not collide with configured Codex pool-account ids or account selector targets"}, + {"duplicate targets allowed", `{"providers":{},"codexAccountNamespaces":{"first":"same-account","second":"same-account"}}`, ""}, + {"client optional timestamp", `{"providers":{},"runtimeRole":"client","client":{"serverUrl":"https://hub.example.test","managementUrl":"https://manage.example.test","managementTransport":"direct","selectedClients":["codex"],"tokenEnv":"OPENCODEX_API_AUTH_TOKEN","apiKeyId":"issued-key-id","tokenFingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","protocolVersion":1,"connectedAt":"2026-08-28T00:00:00.000Z","catalogSyncedAt":"bad"}}`, "schema_invalid: client.catalogSyncedAt: Invalid ISO datetime"}, + {"client pending state strict", `{"providers":{},"runtimeRole":"client","client":{"serverUrl":"https://hub.example.test","managementUrl":"https://manage.example.test","managementTransport":"direct","selectedClients":["codex"],"tokenEnv":"OPENCODEX_API_AUTH_TOKEN","apiKeyId":"issued-key-id","tokenFingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","protocolVersion":1,"connectedAt":"2026-08-28T00:00:00.000Z","pendingOperation":{"kind":"rotate","rotationId":"r","newKeyIssuedAt":"2026-08-28T00:00:00.000Z","oldKeyBackupPath":"/tmp/previous","extra":true}}}`, "schema_invalid: client.pendingOperation: Unrecognized key: \"extra\""}, + {"client pending state path", `{"providers":{},"runtimeRole":"client","client":{"serverUrl":"https://hub.example.test","managementUrl":"https://manage.example.test","managementTransport":"direct","selectedClients":["codex"],"tokenEnv":"OPENCODEX_API_AUTH_TOKEN","apiKeyId":"issued-key-id","tokenFingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","protocolVersion":1,"connectedAt":"2026-08-28T00:00:00.000Z","pendingOperation":{"kind":"rotate","rotationId":"r","newKeyIssuedAt":"2026-08-28T00:00:00.000Z","oldKeyBackupPath":"/tmp/foreign"}}}`, "schema_invalid: client.pendingOperation.oldKeyBackupPath: must equal /tmp/ocx37-home/service-api-token.prev"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := ValidateCandidateJSON([]byte(tc.raw)) + if tc.want == "" { + if err != nil { + t.Fatalf("error = %v, want success", err) + } + return + } + if err == nil || err.Error() != tc.want { + t.Fatalf("error = %v, want %q", err, tc.want) + } + }) + } +} diff --git a/go/internal/embeddedui/embeddedui.go b/go/internal/embeddedui/embeddedui.go new file mode 100644 index 0000000000..dee06be006 --- /dev/null +++ b/go/internal/embeddedui/embeddedui.go @@ -0,0 +1,187 @@ +// Package embeddedui serves the dashboard for the standalone ocx runtime. +// +// Resolution order mirrors the TypeScript runtime (src/server/gui-static.ts +// findGuiDist): a live dashboard build under /gui/dist is served when the +// binary runs from a checkout or a packaged tree that carries it; otherwise the +// binary falls back to the small static page embedded below. +// +// static/ carries hand-written source assets only (the thin fallback page and +// the provider-icon set mirrored from gui/public). Generated Vite build output +// is never checked in here — it must not enter git history (the repository +// ignores gui/dist for the same reason) — so a checkout's gui/dist, refreshed +// by the release build, is read from disk instead. +package embeddedui + +import ( + "bytes" + "embed" + "encoding/json" + "io/fs" + "mime" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" +) + +//go:embed static +var files embed.FS + +// Handler serves the dashboard HTTP surface. The caller supplies its version +// because release builds stamp it with ldflags. +type Handler struct { + version string + // findDist returns the absolute path of a live dashboard build to serve, or + // "" when the embedded fallback should be used. nil means the default + // resolver (an upward search from the working directory for gui/dist). + findDist func() string +} + +// NewHandler returns the complete dashboard HTTP surface. The caller supplies +// its version because release builds stamp it with ldflags. +func NewHandler(version string) *Handler { + return &Handler{version: version} +} + +// NewHandlerWithResolver is NewHandler with an explicit dashboard-build +// resolver; tests use it to point at a synthetic build without touching the +// working directory. +func NewHandlerWithResolver(version string, findDist func() string) *Handler { + return &Handler{version: version, findDist: findDist} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if r.URL.Path == "/healthz" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", "service": "opencodex", "version": h.version, + "uptime": 0, "pid": 0, "port": 0, + }) + return + } + if dist := h.dashboardDir(); dist != "" && serveFrom(dist, w, r) { + return + } + serveEmbedded(w, r) +} + +// dashboardDir resolves the runtime dashboard build to serve, if any. +func (h *Handler) dashboardDir() string { + if h.findDist != nil { + return h.findDist() + } + return findGuiDist() +} + +// findGuiDist walks upward from the working directory looking for a gui/dist +// with an index.html, mirroring findGuiDist() in src/server/gui-static.ts. A +// release binary executed from a packaged install finds nothing and falls back +// to the embedded page. +func findGuiDist() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + for { + candidate := filepath.Join(dir, "gui", "dist") + if info, statErr := os.Stat(filepath.Join(candidate, "index.html")); statErr == nil && !info.IsDir() { + return candidate + } + parent := filepath.Dir(dir) + if parent == dir { + return "" + } + dir = parent + } +} + +// serveFrom serves one file from a live dashboard build directory. It reports +// whether the request was fully handled (a file existed, or an extensionless +// path resolved to the build's index.html). +func serveFrom(dist string, w http.ResponseWriter, r *http.Request) bool { + name, spa := embeddedName(r.URL.Path) + if name == "" { + http.NotFound(w, r) + return true + } + body, err := os.ReadFile(filepath.Join(dist, filepath.FromSlash(name))) + if err != nil && spa { + body, err = os.ReadFile(filepath.Join(dist, "index.html")) + if err == nil { + name = "index.html" + } + } + if err != nil { + if os.IsNotExist(err) { + return false + } + http.NotFound(w, r) + return true + } + serveBytes(w, r, name, body) + return true +} + +func serveEmbedded(w http.ResponseWriter, r *http.Request) { + name, spa := embeddedName(r.URL.Path) + if name == "" { + http.NotFound(w, r) + return + } + body, err := fs.ReadFile(files, path.Join("static", name)) + if err != nil && spa { + name, body, err = "index.html", nil, nil + body, err = fs.ReadFile(files, path.Join("static", name)) + } + if err != nil { + http.NotFound(w, r) + return + } + serveBytes(w, r, name, body) +} + +func serveBytes(w http.ResponseWriter, r *http.Request, name string, body []byte) { + contentType := mime.TypeByExtension(path.Ext(name)) + if contentType == "" { + contentType = "application/octet-stream" + } + if strings.HasSuffix(name, ".html") { + contentType = "text/html; charset=utf-8" + } + w.Header().Set("Content-Type", contentType) + if strings.HasSuffix(name, ".html") { + w.Header().Set("Cache-Control", "no-store") + } else { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + w.Header().Set("X-Content-Type-Options", "nosniff") + if r.Method == http.MethodHead { + return + } + http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(body)) +} + +func embeddedName(requestPath string) (name string, spa bool) { + // Path traversal must be refused on the RAW path: path.Clean collapses + // "/provider-icons/../x" to "/x" before any ".." check can see it, and the + // join with the embed or dist root happens after this guard. Both the + // literal form and the URL-encoded form are rejected by net/http before a + // request reaches a handler (r.URL.Path is decoded), so checking the + // decoded path here is sufficient. + if strings.Contains(requestPath, "\\") || strings.Contains(requestPath, "..") { + return "", false + } + cleaned := path.Clean("/" + requestPath) + name = strings.TrimPrefix(cleaned, "/") + if name == "" { + return "index.html", false + } + return name, path.Ext(name) == "" +} diff --git a/go/internal/embeddedui/embeddedui_test.go b/go/internal/embeddedui/embeddedui_test.go new file mode 100644 index 0000000000..f9bc1d30cc --- /dev/null +++ b/go/internal/embeddedui/embeddedui_test.go @@ -0,0 +1,156 @@ +package embeddedui + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func get(t *testing.T, handler http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil)) + return response +} + +// fallbackHandler pins the resolver to "no live build" so fallback tests stay +// hermetic even when the checkout carries a gui/dist build. +func fallbackHandler() http.Handler { + return NewHandlerWithResolver("9.9.9", func() string { return "" }) +} + +func TestFallbackServesThinDashboardAndHealth(t *testing.T) { + handler := fallbackHandler() + for _, test := range []struct{ path, wantBody string }{ + {"/", "opencodex proxy dashboard"}, + {"/dashboard/providers", "opencodex proxy dashboard"}, // SPA fallback to index.html + } { + response := get(t, handler, test.path) + if response.Code != http.StatusOK { + t.Fatalf("%s status = %d", test.path, response.Code) + } + if !strings.Contains(response.Header().Get("Content-Type"), "text/html") { + t.Fatalf("%s content type = %q", test.path, response.Header().Get("Content-Type")) + } + if !strings.Contains(response.Body.String(), test.wantBody) { + t.Fatalf("%s body = %q", test.path, response.Body.String()) + } + } + health := get(t, handler, "/healthz") + if health.Code != http.StatusOK || !strings.Contains(health.Body.String(), "\"service\":\"opencodex\"") { + t.Fatalf("/healthz = %d %q", health.Code, health.Body.String()) + } +} + +func TestFallbackServesProviderIconsFromMirroredSource(t *testing.T) { + handler := fallbackHandler() + for _, path := range []string{"/favicon.png", "/provider-icons/openai.svg", "/icons.svg"} { + response := get(t, handler, path) + if response.Code != http.StatusOK || response.Body.Len() == 0 { + t.Fatalf("GET %s = %d len=%d", path, response.Code, response.Body.Len()) + } + } +} + +func TestLiveDashboardOverlayTakesPrecedence(t *testing.T) { + dist := t.TempDir() + assets := filepath.Join(dist, "assets") + if err := os.MkdirAll(assets, 0o755); err != nil { + t.Fatal(err) + } + liveIndex := "live" + if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte(liveIndex), 0o644); err != nil { + t.Fatal(err) + } + liveAsset := "console.log('live');" + if err := os.WriteFile(filepath.Join(assets, "index-deadbeef.js"), []byte(liveAsset), 0o644); err != nil { + t.Fatal(err) + } + handler := NewHandlerWithResolver("9.9.9", func() string { return dist }) + + root := get(t, handler, "/") + if root.Code != http.StatusOK || root.Body.String() != liveIndex { + t.Fatalf("/ = %d %q, want the live build", root.Code, root.Body.String()) + } + asset := get(t, handler, "/assets/index-deadbeef.js") + if asset.Code != http.StatusOK || asset.Body.String() != liveAsset { + t.Fatalf("asset = %d %q, want the live build file", asset.Code, asset.Body.String()) + } + // A request that exists only in the embedded fallback must not shadow the + // live build: unknown paths 404 instead of silently serving the fallback. + missing := get(t, handler, "/assets/nope.js") + if missing.Code != http.StatusNotFound { + t.Fatalf("missing asset = %d, want 404", missing.Code) + } + // Extensionless SPA paths fall back to the live build's index.html. + spa := get(t, handler, "/logs") + if spa.Code != http.StatusOK || spa.Body.String() != liveIndex { + t.Fatalf("/logs = %d %q, want live index", spa.Code, spa.Body.String()) + } +} + +func TestFallbackStillServedWhenLiveBuildLacksFile(t *testing.T) { + dist := t.TempDir() + if err := os.WriteFile(filepath.Join(dist, "index.html"), []byte("live"), 0o644); err != nil { + t.Fatal(err) + } + handler := NewHandlerWithResolver("9.9.9", func() string { return dist }) + // favicon.png lives in the embedded tree (mirrored from gui/public), not in + // the Vite build output; a live build must still fall back to the embed for + // assets it does not ship. + response := get(t, handler, "/favicon.png") + if response.Code != http.StatusOK || response.Body.Len() == 0 { + t.Fatalf("favicon = %d len=%d, want embedded fallback", response.Code, response.Body.Len()) + } +} + +func TestHandlerRejectsEscapingPathsAndUnknownAsset(t *testing.T) { + handler := fallbackHandler() + for _, path := range []string{"/../go.mod", "/assets/missing.js", "/provider-icons/../secret"} { + response := get(t, handler, path) + if response.Code != http.StatusNotFound { + t.Fatalf("%s status = %d, want 404", path, response.Code) + } + } +} + +func TestHandlerHeadAndMethodGuard(t *testing.T) { + handler := fallbackHandler() + head := httptest.NewRecorder() + handler.ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/", nil)) + if head.Code != http.StatusOK { + t.Fatalf("HEAD / = %d", head.Code) + } + post := httptest.NewRecorder() + handler.ServeHTTP(post, httptest.NewRequest(http.MethodPost, "/", nil)) + if post.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST / = %d, want 405", post.Code) + } + _ = io.Discard +} + +func TestFindGuiDistWalksUpFromWorkingDirectory(t *testing.T) { + original, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(original) }) + // The embeddedui package lives under /go/internal/embeddedui; the + // repository root's gui/dist must be found two levels up. gui/dist exists + // only when a GUI build ran locally, so make the assertion tolerant: when + // the tree has no gui/dist, the walk must still terminate and find nothing + // instead of walking off the filesystem root. + got := findGuiDist() + root := filepath.Dir(filepath.Dir(filepath.Dir(original))) + if info, statErr := os.Stat(filepath.Join(root, "gui", "dist", "index.html")); statErr == nil && !info.IsDir() { + if got != filepath.Join(root, "gui", "dist") { + t.Fatalf("findGuiDist() = %q, want %q", got, filepath.Join(root, "gui", "dist")) + } + } else if got != "" { + t.Fatalf("findGuiDist() = %q, want \"\" when no gui/dist exists", got) + } +} diff --git a/go/internal/embeddedui/static/favicon.png b/go/internal/embeddedui/static/favicon.png new file mode 100644 index 0000000000..3a50bfa241 Binary files /dev/null and b/go/internal/embeddedui/static/favicon.png differ diff --git a/go/internal/embeddedui/static/icons.svg b/go/internal/embeddedui/static/icons.svg new file mode 100644 index 0000000000..e9522193d9 --- /dev/null +++ b/go/internal/embeddedui/static/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/go/internal/embeddedui/static/index.html b/go/internal/embeddedui/static/index.html new file mode 100644 index 0000000000..45c4fa01a8 --- /dev/null +++ b/go/internal/embeddedui/static/index.html @@ -0,0 +1,78 @@ + + + + + + + opencodex · proxy dashboard + + + +
+

opencodex proxy dashboard

+

The full dashboard build is not available in this standalone runtime.

+

Status: checking…

+

+ +

Release builds embed the full Vite dashboard; this page is the + offline fallback when no GUI build ships with the runtime. Management API: + http://127.0.0.1:/api/

+
+ + + diff --git a/go/internal/embeddedui/static/logo.png b/go/internal/embeddedui/static/logo.png new file mode 100644 index 0000000000..894ad8ca71 Binary files /dev/null and b/go/internal/embeddedui/static/logo.png differ diff --git a/go/internal/embeddedui/static/provider-icons/README.md b/go/internal/embeddedui/static/provider-icons/README.md new file mode 100644 index 0000000000..1fc7c57857 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/README.md @@ -0,0 +1,248 @@ +Provider logo assets for the dashboard. + +Sources: + +- Existing baseline copied from `../cli-jaw/public/assets/providers`. +- Additional candidates copied from `devlog/_plan/260705_provider-quota-dashboard/svg-candidates`. + +License/source notes for the additional candidates are recorded in +`devlog/_fin/260705_provider-quota-dashboard/21_svg_candidates.md` and its +`svg-candidates/manifest.json` (that unit has since closed, so the path is under +`_fin/` rather than `_plan/`). + +Export-client marks (used by the API tab's connect rows, not the provider list): + +- `pi.svg` — fetched 2026-08-02 from `https://pi.dev/favicon.svg`, the Pi + project's own favicon, unmodified. Pi is `earendil-works/pi` + (formerly `badlogic/pi-mono`). +- `opencode.svg` — part of the existing baseline above; the API tab reuses it as + the OpenCode export-client mark. +- `oh-my-pi.svg` — fetched 2026-08-31 from `https://omp.sh/favicon.svg`, the Oh My Pi + project's own favicon, unmodified. Oh My Pi is `can1357/oh-my-pi`. +- `openclaw.svg` — fetched 2026-08-31 from + `https://raw.githubusercontent.com/openclaw/openclaw/main/ui/public/favicon.svg`, + the OpenClaw project's own favicon, unmodified. OpenClaw is `openclaw/openclaw`. +- `deepseek-harness.svg` — fetched 2026-08-31 from + `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/master/website/public/favicon.svg`, + unmodified. DSH is first-party DeepSeek: they publish + `deepseek-ai/deepseek-harness` and scope its packages `@deepseek-ai/dsh-*`. This is + the harness's own mark, deliberately not the `deepseek-color.svg` provider logo. +- `prime-agent.svg` — fetched 2026-08-31 from + `https://raw.githubusercontent.com/PrimeIntellect-ai/prime-agent/main/assets/brand/prime-butterfly.svg`, + unmodified (it carries its authoring editor's metadata). Prime Agent is + `PrimeIntellect-ai/prime-agent`. It has its own mark, so `pi.svg` is not reused for + it even though Prime reads Pi's config contract. +- `zcode.svg` — fetched 2026-08-31 from + `https://z-cdn.chatglm.cn/z-ai/static/logo.svg`, Z.ai's own logo, unmodified (it + carries its authoring tool's generator comment). +- `kimi-color.svg` — already in the baseline as a provider icon; the API tab reuses + it for the Kimi Code client, which is the same Moonshot AI brand. +- `aside.svg` — extracted 2026-08-31 from the installed Aside application, module + `Contents/Frameworks/Aside Framework.framework/Versions/1.0.825.1/Libraries/AsideAgentManager/assets/official-brand-symbol-*.js`. + It is Aside's own brand symbol, named as such by the vendor and rendered by + Aside's onboarding, permission, and settings surfaces. The module is a compiled + React component rather than a file, so the single 24x24 `evenodd` path was + lifted verbatim into a standalone SVG with its original `viewBox` and its + `currentColor` fill; no path data was redrawn. Aside does not publish this mark + on the web (`aside.com/favicon.svg` is a 404), so the shipping application is + the first-party source. + +- `minimax.svg` — fetched 2026-08-31 from + `https://raw.githubusercontent.com/MiniMax-AI/MiniMax-01/main/figures/minimax.svg`, + MiniMax's own symbol as committed in their own model repository. The API-docs + asset (`mintcdn.com/minimax-zh/.../logo/light.svg`) is the 129x32 horizontal + lockup and was rejected: a wordmark in a 20px square is unreadable. This is the + publisher's mark — MiniMax Code ships none of its own — used for the `mcode` + client. Path data is verbatim; the Chinese-language `` and layer-name + metadata the authoring tool left behind are removed, and the gradient id + `未命名的渐变_6` ("unnamed gradient 6") is renamed `minimax-wave` because a + non-ASCII id collides awkwardly across inlined documents. + +Two marks are TRACED rather than fetched. Their vendors publish no usable +vector, and a trace that follows the source pixels is a truer mark than a +monogram. What is still refused either way: a horizontal wordmark squeezed into +this square slot, and a full-frame silhouette plate that renders as a filled box +at 20px. + +- `hermes-agent.svg` — traced 2026-08-31 from + `NousResearch/hermes-agent` `apps/desktop/assets/icon.png` (574273 bytes, + 1024x1024 RGBA), the icon the Hermes desktop application itself ships, so this + is the product's own mark. Two earlier candidates were rejected: + `website/static/img/favicon.svg` is 113 bytes and its entire body is one + `<text>` element with no path data, and `nousresearch.com/safari-pinned-tab.svg` + opens with the full 512-unit frame as its first path, so it renders as a black + square. Traced with + `potrace -s --flat --turdsize 8 --alphamax 1.0 --opttolerance 0.2` over the + mask `alpha > 128 AND mean(rgb) < 110`, which keeps the black artwork and + discards the light plate behind it. One path, `currentColor`, squared to + `viewBox="0 0 823 823"` by centering the 823x806 trace. Named + `hermes-agent` rather than `hermes` because Hermes is also a provider name + and this directory is one flat namespace. +- `gajae-code.svg` — traced 2026-08-31 from `Yeachan-Heo/gajae-code` + `assets/character.png` (3190496 bytes, 1550x2048 RGBA), the mascot. No SVG + exists upstream: `assets/` and `docs/` hold only raster, `public/` is a 404, + the five plausible `logo.svg`/`favicon.svg` paths all 404, no published + `@gajae-code/*` tarball at 0.15.6 contains one, and `docs/brand-assets.md` + lists the marks as PNG. The source is a vertical lockup, so only the mascot is + traced — rows 1650-1682 are fully transparent, which is the seam the crop uses, + and the `gajae-code` wordmark below it is discarded. The artwork is upscaled + pixel art, so tracing at source resolution followed every staircase and gave a + 1.3 MB file; downsampling to a 128px box (Lanczos, then a 0.6px Gaussian) + first gives ~31 KB. Seven color layers, k-means++ seeded at 3 so the + quantization is deterministic, painted largest-area first. The smallest layer + is 292 px and a fixed area floor would have dropped it — it is the visor + green, which is the feature that makes the character recognizable, so the + floor is a fraction of the opaque area instead. + +## How a mark is painted + +Provenance is not the only fact that has to survive a handoff. Every mark is +drawn one of two ways, and picking wrong makes a logo vanish rather than look +slightly off: + +- **image** — the `<svg>` is rendered as-is, keeping its own colors. Correct for + anything multi-color, and for a single ink that *is* the brand. +- **mask** — the file is used as a shape and filled with the surrounding text + color, so it follows the theme. Correct for a neutral silhouette, which would + otherwise be invisible against one of the two surfaces. + +The set lives in `gui/src/components/integration-marks.ts`. It is derived from +`MONOCHROME_CLIENT_MARKS` for export clients, plus `MASKED_NATIVE_MARKS` for rows +that have no export client to be keyed by. + +Decisions that are not obvious from looking at the file: + +- `grok.svg` **is masked.** One `#000000` fill on transparency measured about + 1.9:1 on the dark card surface (`rgb(48,48,48)`) — effectively gone. Masking + does not modify xAI's file; it reads it as a shape, which is how xAI renders it + on their own dark surfaces. 11.17:1 dark and 17.67:1 light afterwards. +- `openai.svg` **is not masked**, despite also being a single fill. That fill is + #10A37F, OpenAI's brand green, and repainting it discards information a reader + uses to identify the mark. Neutrality is the test, not ink count. +- `deepseek-harness.svg` **is not masked** for the same reason: #4d6bfe is + DeepSeek blue. Its dark-theme contrast is adequate; if it ever is not, the fix + is a surface change, not a repaint. +- `hermes-agent.svg` **is masked.** The trace is one near-black path, so it is + invisible on `#0d1117` untinted. Nothing about the Hermes brand is carried by + that particular black. +- `minimax.svg` and `gajae-code.svg` **are not masked.** A gradient wave and a + seven-layer mascot respectively; masking would flatten both to one ink. +- `prime-agent.svg` **is masked.** White on transparency, so as an image it was + invisible in light mode. This one shipped broken. +- `opencode.svg` (#211E1E) and `kimi-color.svg` (#1A1A1A) **are masked.** Both + near-black single inks, invisible in dark mode as images. Both shipped broken + too, which is what established the rule. +- `aside.svg` **is masked.** It already paints with `currentColor`, so it would + follow the theme either way; masking keeps it consistent with the other + silhouettes rather than depending on inherited color. + +Both directions are enforced in `gui/tests/integration-marks.test.ts`, including a +luminance check that fails any single-ink near-neutral mark left as an image. That +direction was missing until it caught `grok`; the same class of defect had already +shipped once for `prime`, `opencode` and `kimi`. + +## Provider marks (2026-09-01) + +Sourced for the providers that were rendering a coloured initial tile. Every +entry below was fetched from the vendor's own domain, taken from the registry's +`baseUrl`/`dashboardUrl` rather than guessed. + +Published as SVG and committed with only comments, `<title>`/`<desc>` and +`data-name` attributes stripped: + +- `digitalocean.svg` — `digitalocean.com` favicon, 32x32. +- `featherless.svg` — `featherless.ai/favicon.svg`, 256x256. +- `kilo.svg` — `kilo.ai/favicon/favicon.svg`, 32x32. Keeps its `oklch()` plate. +- `nanogpt.svg` — `nano-gpt.com/logo.svg`, 181x187, gradient. +- `nebius.svg` — `nebius.com/favicon/favicon.svg`, 96x96. +- `neuralwatt.svg` — the site's Webflow-hosted brand asset, 32x32. +- `parallel.svg` — `parallel.ai/icon.svg`, 96x96. +- `scaleway.svg` — `scaleway.com/favicon/website/favicon.svg`, 16x16. +- `synthetic.svg` — `synthetic.new/favicon.svg`, 54x54. +- `zai.svg` — `z-cdn.chatglm.cn/z-ai/static/logo.svg`, 30x30. +- `zenmux.svg` — the site's CDN-hosted brand mark, 160x160. + +Traced from raster, because the vendor publishes no square SVG mark. Same +technique as `hermes-agent.svg` and `gajae-code.svg`: `potrace -s --flat` for a +single-ink silhouette, k-means colour layers (seeded at 3, largest area first) +for multi-colour art, downsampled to a 160px box first so the trace does not +follow every upscaled pixel edge. + +- `cerebras.svg`, `novita.svg`, `siliconflow.svg`, `deepinfra.svg` — single-ink. +- `baseten.svg`, `hyperbolic.svg`, `sambanova.svg`, `umans.svg`, `venice.svg`, + `vultr.svg`, `bizrouter.svg`, `orcarouter.svg` — colour-layered. +- `nous.svg` — traced from `nousresearch.com/apple-touch-icon.png` (180x180). This + is the Nous Research company mark, distinct from `hermes-agent.svg`, which is + the Hermes product's own icon. Attributing one to the other would be wrong even + though the same organization ships both. + +Found on a docs subdomain after the vendor's marketing site offered only a +wordmark: + +- `together.svg` — `docs.together.ai/favicon.svg`, 1.07:1. +- `litellm.svg` — `docs.litellm.ai/img/logo.svg`, 1:1. The marketing site's SVGs + are third-party model logos, not LiteLLM's own mark. + +**The plate problem, recorded because the first pass shipped it.** A favicon is +usually a glyph on a filled rounded square. Tracing luminance alone captured the +square and produced a solid box: `baseten` came out 97.7% ink, `bizrouter` 89.3%. +The fix reads the border ring, takes its median colour as the plate when the ring +is uniform, and masks by distance from that colour instead of by darkness. It +found real plates behind `baseten` (#19e76e), `cerebras` (#ef5b27), `hyperbolic` +(#1a1a1a), `umans`/`bizrouter` (#000000) and `orcarouter` (#ffffff). + +### Rejected, and why + +- **`nousresearch.com/safari-pinned-tab.svg`** — opens with the full 512-unit + frame as its first path, so it renders as a black square. This is the identical + candidate the Hermes client mark rejected. The apple-touch-icon was used instead. +- **LiteLLM's marketing-site SVGs** — third-party model logos, and the favicon + traces to a muddy blob with no legible silhouette at 19px. The docs logo was + used instead. +- **Wordmarks refused** for `cerebras` (843x320 from Sanity CDN), `siliconflow` + (188x28), `zhipu-bigmodel` (123x25), `vultr` (218x52), `baseten` (1001x151) and + `chutes` (192x30). A lockup in the rail's 19px box is an illegible smear, so + each was replaced by a traced square mark or left to the fallback. + +### Still unmarked, and what was searched + +Six ids keep the fallback tile. Each was probed at its registry `baseUrl` and +`dashboardUrl`, plus the vendor's docs subdomain and the conventional icon paths +(`/favicon.svg`, `/favicon.ico`, `/apple-touch-icon.png`, `/logo.svg`, `/icon.svg`): + +- `chutes` — `chutes.ai` and `docs.chutes.ai` serve only the 192x30 wordmark. +- `nscale` — `nscale.com` and `docs.nscale.com` returned no icon at any path. +- `volcengine`, `volcengine-coding-plan`, `volcengine-agent-plan` — the Ark + console's SVGs are UI glyphs rather than a product mark. +- `tencent-coding-plan` — `cloud.tencent.com` serves a 32x32 favicon whose trace + is unreadable at 19px. + +These are recorded results, not skipped work. Inventing a mark, or borrowing a +neighbouring brand's, is a misattribution that outlives the commit. + +`zhipu-bigmodel` and `zhipu-bigmodel-coding` share `zai.svg`: Z.AI and BigModel +are the same company, and the mainland console publishes only the wordmark. + +## Meta (2026-09-03) + +- `meta.svg` — the `aria-label="Meta symbol"` inline SVG that `dev.meta.ai` + renders in its own navigation header, read 2026-09-03 through a signed-in + browser session. Meta publishes no square vector at the conventional paths: + `dev.meta.ai/favicon.svg`, `/icon.svg` and `/logo.svg` all 404, and the + site's declared icon is a 32x32 `.ico` on `static.xx.fbcdn.net`. The rendered + header mark is therefore the first-party vector, taken from the developer + console the two providers actually belong to. + + Path data and gradient stops are verbatim. Three normalizations: React's + generated gradient ids (`_r_d_`, `_r_e_`, `_r_f_`) become + `meta-mark-a/-b/-c`, because a generated id collides when several marks are + inlined into one document — the same reason `minimax.svg` renamed its + `未命名的渐变_6`; the presentational `height`/`width`/`role`/`aria-label` + are dropped in favour of the `viewBox`; and `xmlns` is added so the file + stands alone. + + Wired to both `meta-model` (the direct Meta Model API provider) and + `meta-muse` (the Muse Code credential import). One brand, two credentials — + the same shape as the three Alibaba ids sharing `alibaba-color.svg`. + **Not masked:** three linear gradients in Meta brand blue + (#0064E0 -> #0278F1), and masking flattens a gradient to a single ink. diff --git a/go/internal/embeddedui/static/provider-icons/alibaba-color.svg b/go/internal/embeddedui/static/provider-icons/alibaba-color.svg new file mode 100644 index 0000000000..69e374735d --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/alibaba-color.svg @@ -0,0 +1 @@ +<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Alibaba Cloud \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/antigravity-color.svg b/go/internal/embeddedui/static/provider-icons/antigravity-color.svg new file mode 100644 index 0000000000..bf746e1246 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/antigravity-color.svg @@ -0,0 +1 @@ +Antigravity \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/aside.svg b/go/internal/embeddedui/static/provider-icons/aside.svg new file mode 100644 index 0000000000..a303dd7e60 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/aside.svg @@ -0,0 +1,3 @@ + + + diff --git a/go/internal/embeddedui/static/provider-icons/baseten.svg b/go/internal/embeddedui/static/provider-icons/baseten.svg new file mode 100644 index 0000000000..2fa74a269b --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/baseten.svg @@ -0,0 +1,13 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/bizrouter.svg b/go/internal/embeddedui/static/provider-icons/bizrouter.svg new file mode 100644 index 0000000000..f0644866a3 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/bizrouter.svg @@ -0,0 +1,41 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/cerebras.svg b/go/internal/embeddedui/static/provider-icons/cerebras.svg new file mode 100644 index 0000000000..56d52a3d3c --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/cerebras.svg @@ -0,0 +1,26 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + diff --git a/go/internal/embeddedui/static/provider-icons/claude-color.svg b/go/internal/embeddedui/static/provider-icons/claude-color.svg new file mode 100644 index 0000000000..9c2cb59c26 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/claude-color.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/cline-color.svg b/go/internal/embeddedui/static/provider-icons/cline-color.svg new file mode 100644 index 0000000000..90a30f1acc --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/cline-color.svg @@ -0,0 +1,16 @@ + + + Cline + + + + + + + + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/cloudflare-ai-gateway-color.svg b/go/internal/embeddedui/static/provider-icons/cloudflare-ai-gateway-color.svg new file mode 100644 index 0000000000..cf80692961 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/cloudflare-ai-gateway-color.svg @@ -0,0 +1 @@ +Cloudflare \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/commandcode-color.svg b/go/internal/embeddedui/static/provider-icons/commandcode-color.svg new file mode 100644 index 0000000000..4f257b4ecc --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/commandcode-color.svg @@ -0,0 +1 @@ +Command Code diff --git a/go/internal/embeddedui/static/provider-icons/copilot-color.svg b/go/internal/embeddedui/static/provider-icons/copilot-color.svg new file mode 100644 index 0000000000..c5b411ad2f --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/copilot-color.svg @@ -0,0 +1 @@ +Copilot \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/cursor-color.svg b/go/internal/embeddedui/static/provider-icons/cursor-color.svg new file mode 100644 index 0000000000..d903610064 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/cursor-color.svg @@ -0,0 +1,2 @@ + +Cursor \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/deepinfra.svg b/go/internal/embeddedui/static/provider-icons/deepinfra.svg new file mode 100644 index 0000000000..44c1eb0f4f --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/deepinfra.svg @@ -0,0 +1,75 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + diff --git a/go/internal/embeddedui/static/provider-icons/deepseek-color.svg b/go/internal/embeddedui/static/provider-icons/deepseek-color.svg new file mode 100644 index 0000000000..f3be195b8e --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/deepseek-color.svg @@ -0,0 +1 @@ +DeepSeek \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/deepseek-harness.svg b/go/internal/embeddedui/static/provider-icons/deepseek-harness.svg new file mode 100644 index 0000000000..653b77e157 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/deepseek-harness.svg @@ -0,0 +1,3 @@ + + + diff --git a/go/internal/embeddedui/static/provider-icons/digitalocean.svg b/go/internal/embeddedui/static/provider-icons/digitalocean.svg new file mode 100644 index 0000000000..2d9a943e90 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/digitalocean.svg @@ -0,0 +1,10 @@ + + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/discord.svg b/go/internal/embeddedui/static/provider-icons/discord.svg new file mode 100644 index 0000000000..0440ab877a --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/discord.svg @@ -0,0 +1 @@ +Discord \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/featherless.svg b/go/internal/embeddedui/static/provider-icons/featherless.svg new file mode 100644 index 0000000000..dd171d68e4 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/featherless.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/firepass-color.svg b/go/internal/embeddedui/static/provider-icons/firepass-color.svg new file mode 100644 index 0000000000..01f493c58c --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/firepass-color.svg @@ -0,0 +1 @@ +Fireworks \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/fireworks-color.svg b/go/internal/embeddedui/static/provider-icons/fireworks-color.svg new file mode 100644 index 0000000000..01f493c58c --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/fireworks-color.svg @@ -0,0 +1 @@ +Fireworks \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/gajae-code.svg b/go/internal/embeddedui/static/provider-icons/gajae-code.svg new file mode 100644 index 0000000000..d4705e6ac3 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/gajae-code.svg @@ -0,0 +1,410 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/gemini-color.svg b/go/internal/embeddedui/static/provider-icons/gemini-color.svg new file mode 100644 index 0000000000..822a18de8a --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/gemini-color.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/github-copilot-color.svg b/go/internal/embeddedui/static/provider-icons/github-copilot-color.svg new file mode 100644 index 0000000000..e713560a88 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/github-copilot-color.svg @@ -0,0 +1 @@ +GitHub Copilot \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/gitlab-duo-color.svg b/go/internal/embeddedui/static/provider-icons/gitlab-duo-color.svg new file mode 100644 index 0000000000..562154d5d3 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/gitlab-duo-color.svg @@ -0,0 +1 @@ +GitLab \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/grok.svg b/go/internal/embeddedui/static/provider-icons/grok.svg new file mode 100644 index 0000000000..7374eefe94 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/grok.svg @@ -0,0 +1 @@ +Grok diff --git a/go/internal/embeddedui/static/provider-icons/groq-color.svg b/go/internal/embeddedui/static/provider-icons/groq-color.svg new file mode 100644 index 0000000000..2eecc367cc --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/groq-color.svg @@ -0,0 +1 @@ +Groq \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/hermes-agent.svg b/go/internal/embeddedui/static/provider-icons/hermes-agent.svg new file mode 100644 index 0000000000..4761dd73fa --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/hermes-agent.svg @@ -0,0 +1,207 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/huggingface-color.svg b/go/internal/embeddedui/static/provider-icons/huggingface-color.svg new file mode 100644 index 0000000000..2267427337 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/huggingface-color.svg @@ -0,0 +1 @@ +Hugging Face \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/hyperbolic.svg b/go/internal/embeddedui/static/provider-icons/hyperbolic.svg new file mode 100644 index 0000000000..a71713acf5 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/hyperbolic.svg @@ -0,0 +1,18 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/kilo.svg b/go/internal/embeddedui/static/provider-icons/kilo.svg new file mode 100644 index 0000000000..c4b6bb8ba4 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/kilo.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/kimi-color.svg b/go/internal/embeddedui/static/provider-icons/kimi-color.svg new file mode 100644 index 0000000000..39c3e2f9b2 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/kimi-color.svg @@ -0,0 +1 @@ +Moonshot AI \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/kiro-color.svg b/go/internal/embeddedui/static/provider-icons/kiro-color.svg new file mode 100644 index 0000000000..0cf048bec2 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/kiro-color.svg @@ -0,0 +1,15 @@ + + +Kiro + + + + + + + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/litellm.svg b/go/internal/embeddedui/static/provider-icons/litellm.svg new file mode 100644 index 0000000000..9db6d0d066 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/litellm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/lm-studio-color.svg b/go/internal/embeddedui/static/provider-icons/lm-studio-color.svg new file mode 100644 index 0000000000..c50f3a6526 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/lm-studio-color.svg @@ -0,0 +1 @@ +LM Studio \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/meta.svg b/go/internal/embeddedui/static/provider-icons/meta.svg new file mode 100644 index 0000000000..59d5570e57 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/meta.svg @@ -0,0 +1 @@ + diff --git a/go/internal/embeddedui/static/provider-icons/minimax.svg b/go/internal/embeddedui/static/provider-icons/minimax.svg new file mode 100644 index 0000000000..31b734a6cd --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/minimax.svg @@ -0,0 +1 @@ + diff --git a/go/internal/embeddedui/static/provider-icons/mistral-color.svg b/go/internal/embeddedui/static/provider-icons/mistral-color.svg new file mode 100644 index 0000000000..a02c1ba31c --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/mistral-color.svg @@ -0,0 +1 @@ +Mistral AI \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/moonshot-color.svg b/go/internal/embeddedui/static/provider-icons/moonshot-color.svg new file mode 100644 index 0000000000..ed2bbfbfe5 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/moonshot-color.svg @@ -0,0 +1 @@ +Moonshot AI \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/nanogpt.svg b/go/internal/embeddedui/static/provider-icons/nanogpt.svg new file mode 100644 index 0000000000..4344909a8b --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/nanogpt.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/nebius.svg b/go/internal/embeddedui/static/provider-icons/nebius.svg new file mode 100644 index 0000000000..4d55b7bf0b --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/nebius.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/neuralwatt.svg b/go/internal/embeddedui/static/provider-icons/neuralwatt.svg new file mode 100644 index 0000000000..3339f995c7 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/neuralwatt.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/nous.svg b/go/internal/embeddedui/static/provider-icons/nous.svg new file mode 100644 index 0000000000..5307d5feb2 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/nous.svg @@ -0,0 +1,149 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + diff --git a/go/internal/embeddedui/static/provider-icons/novita.svg b/go/internal/embeddedui/static/provider-icons/novita.svg new file mode 100644 index 0000000000..75f64f25a4 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/novita.svg @@ -0,0 +1,32 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + diff --git a/go/internal/embeddedui/static/provider-icons/nvidia-color.svg b/go/internal/embeddedui/static/provider-icons/nvidia-color.svg new file mode 100644 index 0000000000..c3ac1b8df2 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/nvidia-color.svg @@ -0,0 +1 @@ +NVIDIA \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/oh-my-pi.svg b/go/internal/embeddedui/static/provider-icons/oh-my-pi.svg new file mode 100644 index 0000000000..553490c5e9 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/oh-my-pi.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/go/internal/embeddedui/static/provider-icons/ollama-color.svg b/go/internal/embeddedui/static/provider-icons/ollama-color.svg new file mode 100644 index 0000000000..1e3879cc60 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/ollama-color.svg @@ -0,0 +1 @@ +Ollama \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/openai.svg b/go/internal/embeddedui/static/provider-icons/openai.svg new file mode 100644 index 0000000000..ef1ef3096d --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/openai.svg @@ -0,0 +1 @@ +OpenAI diff --git a/go/internal/embeddedui/static/provider-icons/openclaw.svg b/go/internal/embeddedui/static/provider-icons/openclaw.svg new file mode 100644 index 0000000000..dfa44629a2 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/openclaw.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/go/internal/embeddedui/static/provider-icons/opencode.svg b/go/internal/embeddedui/static/provider-icons/opencode.svg new file mode 100644 index 0000000000..d617075e7f --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/opencode.svg @@ -0,0 +1,2 @@ + +OpenCodeX diff --git a/go/internal/embeddedui/static/provider-icons/openrouter-color.svg b/go/internal/embeddedui/static/provider-icons/openrouter-color.svg new file mode 100644 index 0000000000..e0f98db715 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/openrouter-color.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/orcarouter.svg b/go/internal/embeddedui/static/provider-icons/orcarouter.svg new file mode 100644 index 0000000000..4cd5bf9f47 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/orcarouter.svg @@ -0,0 +1,175 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/parallel.svg b/go/internal/embeddedui/static/provider-icons/parallel.svg new file mode 100644 index 0000000000..5d87359bf9 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/parallel.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/pi.svg b/go/internal/embeddedui/static/provider-icons/pi.svg new file mode 100644 index 0000000000..976e4ba744 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/pi.svg @@ -0,0 +1,21 @@ + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/prime-agent.svg b/go/internal/embeddedui/static/provider-icons/prime-agent.svg new file mode 100644 index 0000000000..e0009d14ce --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/prime-agent.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/go/internal/embeddedui/static/provider-icons/qianfan-color.svg b/go/internal/embeddedui/static/provider-icons/qianfan-color.svg new file mode 100644 index 0000000000..fa72fb221b --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/qianfan-color.svg @@ -0,0 +1 @@ +Baidu \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/qwen-portal-color.svg b/go/internal/embeddedui/static/provider-icons/qwen-portal-color.svg new file mode 100644 index 0000000000..bcbcdd90b8 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/qwen-portal-color.svg @@ -0,0 +1 @@ +QWen \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/sambanova.svg b/go/internal/embeddedui/static/provider-icons/sambanova.svg new file mode 100644 index 0000000000..341c74ea0f --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/sambanova.svg @@ -0,0 +1,276 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/scaleway.svg b/go/internal/embeddedui/static/provider-icons/scaleway.svg new file mode 100644 index 0000000000..4996b509b4 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/scaleway.svg @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/siliconflow.svg b/go/internal/embeddedui/static/provider-icons/siliconflow.svg new file mode 100644 index 0000000000..8537ff8e30 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/siliconflow.svg @@ -0,0 +1,18 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + diff --git a/go/internal/embeddedui/static/provider-icons/synthetic.svg b/go/internal/embeddedui/static/provider-icons/synthetic.svg new file mode 100644 index 0000000000..87dfcb7383 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/synthetic.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/telegram.svg b/go/internal/embeddedui/static/provider-icons/telegram.svg new file mode 100644 index 0000000000..df6cc589d0 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/telegram.svg @@ -0,0 +1 @@ +Telegram \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/together.svg b/go/internal/embeddedui/static/provider-icons/together.svg new file mode 100644 index 0000000000..2fcc258c45 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/together.svg @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/umans.svg b/go/internal/embeddedui/static/provider-icons/umans.svg new file mode 100644 index 0000000000..c6176f143a --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/umans.svg @@ -0,0 +1,30 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/venice.svg b/go/internal/embeddedui/static/provider-icons/venice.svg new file mode 100644 index 0000000000..d914e79c3b --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/venice.svg @@ -0,0 +1,165 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/vercel-ai-gateway-color.svg b/go/internal/embeddedui/static/provider-icons/vercel-ai-gateway-color.svg new file mode 100644 index 0000000000..054f1df640 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/vercel-ai-gateway-color.svg @@ -0,0 +1 @@ +Vercel \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/vllm-color.svg b/go/internal/embeddedui/static/provider-icons/vllm-color.svg new file mode 100644 index 0000000000..f8a5a23134 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/vllm-color.svg @@ -0,0 +1 @@ +vLLM \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/vultr.svg b/go/internal/embeddedui/static/provider-icons/vultr.svg new file mode 100644 index 0000000000..69e60c18d9 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/vultr.svg @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/xiaomi-color.svg b/go/internal/embeddedui/static/provider-icons/xiaomi-color.svg new file mode 100644 index 0000000000..68457a9783 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/xiaomi-color.svg @@ -0,0 +1 @@ +Xiaomi \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/zai.svg b/go/internal/embeddedui/static/provider-icons/zai.svg new file mode 100644 index 0000000000..536f3521ee --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/zai.svg @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/go/internal/embeddedui/static/provider-icons/zcode.svg b/go/internal/embeddedui/static/provider-icons/zcode.svg new file mode 100644 index 0000000000..4f511bd72f --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/zcode.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/go/internal/embeddedui/static/provider-icons/zenmux.svg b/go/internal/embeddedui/static/provider-icons/zenmux.svg new file mode 100644 index 0000000000..0f0f3b1800 --- /dev/null +++ b/go/internal/embeddedui/static/provider-icons/zenmux.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/go/internal/jsonwire/jsonwire.go b/go/internal/jsonwire/jsonwire.go new file mode 100644 index 0000000000..e6f39b9f67 --- /dev/null +++ b/go/internal/jsonwire/jsonwire.go @@ -0,0 +1,505 @@ +// Package jsonwire implements the JSON value model the Go hot-path relay +// needs to reproduce TypeScript's byte behaviour when it re-serialises a +// parsed Responses payload (ticket #27, devlog 036). +// +// The hard requirement is that re-encoding a parsed value emits exactly what +// ECMAScript JSON.stringify would emit, because the TS oracle re-serialises a +// repaired upstream body with JSON.stringify and the differential compares raw +// client-visible bytes: +// +// - Object keys keep document order (encoding/json maps would discard it). +// - Strings are escaped exactly like V8: quotes/backslashes and the five +// control shortcuts, \u00xx for other controls, and everything above +// U+0020 — DEL, U+0080, U+2028/U+2029 included — emitted literally as +// UTF-8 (no HTML escaping). +// - Numbers are re-serialised from the parsed float64 the way V8's +// Number::toString does (shortest round-trip decimal, exponent form only +// outside the (-6, 21] decimal window, no zero-padded exponents), NOT with +// encoding/json's rules. +// +// Untouched payloads must never be routed through this encoder: the relay +// emits the original raw bytes when a repair changes nothing, exactly like the +// TS bounded-JSON path, so a lost canonicalisation is only ever observable on +// a payload the repair actually rewrote. +package jsonwire + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "sort" + "strconv" +) + +// Kind classifies a Value. +type Kind int + +const ( + Null Kind = iota + Bool + Number + String + Array + Object +) + +// Member is one object member in document order. +type Member struct { + Key string + Value *Value +} + +// Value is one JSON value with object keys in document order and numbers kept +// as their raw JSON literal until encode time. +type Value struct { + kind Kind + b bool + num string // raw literal for Number + str string // decoded string for String + arr []*Value + obj []Member +} + +// Parse decodes one JSON document into an ordered value tree. A second value +// in the stream is an error, matching the config echo loader's contract. +func Parse(data []byte) (*Value, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + value, err := decodeNext(decoder) + if err != nil { + return nil, err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return nil, errors.New("jsonwire: input contains more than one JSON value") + } + return nil, err + } + return value, nil +} + +func decodeNext(decoder *json.Decoder) (*Value, error) { + token, err := decoder.Token() + if err != nil { + return nil, err + } + return decodeValue(decoder, token) +} + +func decodeValue(decoder *json.Decoder, token json.Token) (*Value, error) { + switch typed := token.(type) { + case nil: + return &Value{kind: Null}, nil + case bool: + return &Value{kind: Bool, b: typed}, nil + case string: + return &Value{kind: String, str: typed}, nil + case json.Number: + return &Value{kind: Number, num: typed.String()}, nil + case json.Delim: + switch typed { + case '{': + obj := &Value{kind: Object} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return nil, err + } + key, ok := keyToken.(string) + if !ok { + return nil, errors.New("jsonwire: object key is not a string") + } + member, err := decodeNext(decoder) + if err != nil { + return nil, err + } + // JSON.parse keeps the last duplicate value but does not move the + // property's original insertion position. Preserve that observable + // V8 object semantics for any payload we later re-serialize. + replaced := false + for i := range obj.obj { + if obj.obj[i].Key == key { + obj.obj[i].Value = member + replaced = true + break + } + } + if !replaced { + obj.obj = append(obj.obj, Member{Key: key, Value: member}) + } + } + if _, err := decoder.Token(); err != nil { // consume '}' + return nil, err + } + return obj, nil + case '[': + arr := &Value{kind: Array} + for decoder.More() { + member, err := decodeNext(decoder) + if err != nil { + return nil, err + } + arr.arr = append(arr.arr, member) + } + if _, err := decoder.Token(); err != nil { // consume ']' + return nil, err + } + return arr, nil + default: + return nil, errors.New("jsonwire: unexpected delimiter") + } + default: + return nil, errors.New("jsonwire: unsupported token") + } +} + +// Kind reports the value's kind. +func (v *Value) Kind() Kind { + if v == nil { + return Null + } + return v.kind +} + +// Bool returns a Bool value's payload. +func (v *Value) Bool() bool { return v != nil && v.kind == Bool && v.b } + +// NumberRaw returns a Number value's raw JSON literal. +func (v *Value) NumberRaw() string { + if v == nil || v.kind != Number { + return "" + } + return v.num +} + +// String returns a String value's decoded payload. +func (v *Value) String() string { + if v == nil || v.kind != String { + return "" + } + return v.str +} + +// Members returns an Object's members in document order. The returned slice is +// a copy. +func (v *Value) Members() []Member { + if v == nil || v.kind != Object { + return nil + } + return append([]Member(nil), v.obj...) +} + +// Elements returns an Array's elements in document order. The returned slice +// is a copy. +func (v *Value) Elements() []*Value { + if v == nil || v.kind != Array { + return nil + } + return append([]*Value(nil), v.arr...) +} + +// Find returns the member with the given key, or nil when absent. The returned +// Value is the live tree node. +func (v *Value) Find(key string) *Value { + if v == nil || v.kind != Object { + return nil + } + for i := range v.obj { + if v.obj[i].Key == key { + return v.obj[i].Value + } + } + return nil +} + +// Set replaces the member with the given key, or appends it at the end when +// absent. Key position is preserved for existing members and new members are +// appended — the exact semantics of a TypeScript object spread. +func (v *Value) Set(key string, member *Value) { + if v == nil || v.kind != Object { + return + } + for i := range v.obj { + if v.obj[i].Key == key { + v.obj[i].Value = member + return + } + } + v.obj = append(v.obj, Member{Key: key, Value: member}) +} + +// ObjectValue creates an empty ordered object for transforms that need to +// synthesize a nested JSON value. +func ObjectValue() *Value { return &Value{kind: Object} } + +// Delete removes an object member while preserving the order of all remaining +// members. It is a no-op for non-objects or absent keys. +func (v *Value) Delete(key string) { + if v == nil || v.kind != Object { + return + } + for i := range v.obj { + if v.obj[i].Key == key { + v.obj = append(v.obj[:i], v.obj[i+1:]...) + return + } + } +} + +// Constructors for the values a repair synthesises (strings, empty arrays, +// booleans). Numbers are not synthesised by the current transforms; NumberFrom +// exists so future transforms can stay on the same tree. +func NullValue() *Value { return &Value{kind: Null} } +func BoolValue(value bool) *Value { return &Value{kind: Bool, b: value} } +func StringValue(value string) *Value { return &Value{kind: String, str: value} } +func EmptyArray() *Value { return &Value{kind: Array} } + +// NumberFrom builds a Number value from a float64. The literal is canonical +// V8 form, so encode round-trips it unchanged. +func NumberFrom(value float64) *Value { + return &Value{kind: Number, num: FormatV8Number(value)} +} + +// AppendArray appends an element to an Array value. +func (v *Value) AppendArray(element *Value) { + if v == nil || v.kind != Array { + return + } + v.arr = append(v.arr, element) +} + +// Encode emits the value exactly like ECMAScript JSON.stringify: compact, no +// HTML/U+2028/U+2029 escaping, array-index object keys in ascending numeric +// order followed by other keys in document order, and numbers in V8 +// shortest-decimal form. +func (v *Value) Encode() ([]byte, error) { + var out bytes.Buffer + if err := v.encode(&out); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +func (v *Value) encode(out *bytes.Buffer) error { + switch v.kind { + case Null: + out.WriteString("null") + case Bool: + if v.b { + out.WriteString("true") + } else { + out.WriteString("false") + } + case Number: + out.WriteString(v8NumberString(v.num)) + case String: + raw, err := EncodeString(v.str) + if err != nil { + return err + } + out.Write(raw) + case Array: + out.WriteByte('[') + for i, member := range v.arr { + if i > 0 { + out.WriteByte(',') + } + if err := member.encode(out); err != nil { + return err + } + } + out.WriteByte(']') + case Object: + out.WriteByte('{') + members := orderedObjectMembers(v.obj) + for i, member := range members { + if i > 0 { + out.WriteByte(',') + } + rawKey, err := EncodeString(member.Key) + if err != nil { + return err + } + out.Write(rawKey) + out.WriteByte(':') + if err := member.Value.encode(out); err != nil { + return err + } + } + out.WriteByte('}') + default: + out.WriteString("null") + } + return nil +} + +// orderedObjectMembers applies ECMAScript's own-property order for the string +// keys that JSON can contain: array-index keys first in ascending numeric +// order, followed by all other keys in their insertion order. +func orderedObjectMembers(members []Member) []Member { + ordered := append([]Member(nil), members...) + sort.SliceStable(ordered, func(i, j int) bool { + left, leftIsIndex := arrayIndex(ordered[i].Key) + right, rightIsIndex := arrayIndex(ordered[j].Key) + if leftIsIndex != rightIsIndex { + return leftIsIndex + } + return leftIsIndex && left < right + }) + return ordered +} + +// arrayIndex returns the numeric value for an ECMAScript array-index property +// key. An index is its canonical decimal spelling in [0, 2^32-2]; 2^32-1 is +// deliberately excluded by the specification. +func arrayIndex(key string) (uint32, bool) { + if key == "0" { + return 0, true + } + if len(key) == 0 || key[0] < '1' || key[0] > '9' || len(key) > 10 { + return 0, false + } + var value uint64 + for i := 0; i < len(key); i++ { + if key[i] < '0' || key[i] > '9' { + return 0, false + } + value = value*10 + uint64(key[i]-'0') + } + if value >= (1<<32)-1 { + return 0, false + } + return uint32(value), true +} + +// EncodeString encodes one string the way ECMAScript JSON.stringify does. +// encoding/json cannot be used directly: with HTML escaping disabled it still +// escapes U+2028/U+2029, while V8 emits them literally (verified against Bun). +func EncodeString(value string) ([]byte, error) { + var out bytes.Buffer + out.WriteByte('"') + for _, r := range value { + switch r { + case '"', '\\': + out.WriteByte('\\') + out.WriteRune(r) + case '\b': + out.WriteString(`\b`) + case '\t': + out.WriteString(`\t`) + case '\n': + out.WriteString(`\n`) + case '\f': + out.WriteString(`\f`) + case '\r': + out.WriteString(`\r`) + default: + if r < 0x20 { + const hex = "0123456789abcdef" + out.WriteString(`\u00`) + out.WriteByte(hex[r>>4]) + out.WriteByte(hex[r&0xf]) + } else { + out.WriteRune(r) + } + } + } + out.WriteByte('"') + return out.Bytes(), nil +} + +// v8NumberString re-serialises a raw JSON number literal the way V8's +// JSON.stringify would after parsing it to a JS Number. +func v8NumberString(raw string) string { + f, err := strconv.ParseFloat(raw, 64) + if err != nil { + // The literal came from a valid JSON decoder, so this cannot fail; + // keep the raw literal rather than inventing bytes. + return raw + } + return FormatV8Number(f) +} + +// FormatV8Number formats a float64 exactly like ECMAScript Number::toString(10) +// (which JSON.stringify uses). Zero (including -0) is "0". +func FormatV8Number(f float64) string { + if f == 0 { + return "0" + } + if f < 0 { + return "-" + formatV8Positive(-f) + } + return formatV8Positive(f) +} + +// formatV8Positive assumes f > 0. +func formatV8Positive(f float64) string { + // strconv's shortest 'e' form is the correctly-rounded shortest decimal + // (the same number ECMAScript's toString algorithm produces), e.g. + // "1.2345e+20", "1e-07". Rewrite it into ECMAScript's formatting rules: + // decimal notation when -6 < s <= 21 (s = decimal exponent of the first + // significant digit), exponent form otherwise with an unpadded exponent. + short := strconv.FormatFloat(f, 'e', -1, 64) + expPos := -1 + for i := len(short) - 1; i >= 0; i-- { + if short[i] == 'e' { + expPos = i + break + } + } + mantissa := short[:expPos] + exp, _ := strconv.Atoi(short[expPos+1:]) + digits := make([]byte, 0, len(mantissa)) + for _, c := range []byte(mantissa) { + if c != '.' { + digits = append(digits, c) + } + } + // s = 1 + exp: the place value of the first digit relative to the units. + s := 1 + exp + k := len(digits) + + if s > 21 || s <= -6 { + var out bytes.Buffer + out.WriteByte(digits[0]) + if k > 1 { + out.WriteByte('.') + out.Write(digits[1:]) + } + out.WriteByte('e') + exponent := s - 1 + if exponent >= 0 { + out.WriteByte('+') + } else { + out.WriteByte('-') + exponent = -exponent + } + out.WriteString(strconv.Itoa(exponent)) + return out.String() + } + if s >= k { + // Integer: pad with zeros up to the decimal position. + out := make([]byte, 0, s) + out = append(out, digits...) + for i := k; i < s; i++ { + out = append(out, '0') + } + return string(out) + } + if s > 0 { + out := make([]byte, 0, k+1) + out = append(out, digits[:s]...) + out = append(out, '.') + out = append(out, digits[s:]...) + return string(out) + } + // 0.00…digits + out := make([]byte, 0, k+2-s) + out = append(out, '0', '.') + for i := 0; i < -s; i++ { + out = append(out, '0') + } + out = append(out, digits...) + return string(out) +} diff --git a/go/internal/jsonwire/jsonwire_test.go b/go/internal/jsonwire/jsonwire_test.go new file mode 100644 index 0000000000..86dc3af6ab --- /dev/null +++ b/go/internal/jsonwire/jsonwire_test.go @@ -0,0 +1,191 @@ +package jsonwire + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// numberCorpusRow is one literal/expected pair from the committed Bun corpus +// (.tmp/gen-number-corpus.mjs): the literal is JSON text, the expected column +// is JSON.stringify(Number(literal)) as V8 emits it. +func numberCorpusRows(t *testing.T) [][2]string { + t.Helper() + file, err := os.Open(filepath.Join("testdata", "v8-numbers.tsv")) + if err != nil { + t.Fatalf("open corpus: %v", err) + } + defer file.Close() + var rows [][2]string + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + fields := strings.Split(scanner.Text(), "\t") + if len(fields) != 2 { + t.Fatalf("malformed corpus row %q", scanner.Text()) + } + rows = append(rows, [2]string{fields[0], fields[1]}) + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan corpus: %v", err) + } + if len(rows) == 0 { + t.Fatal("corpus is empty") + } + return rows +} + +// TestFormatV8NumberAgainstBunCorpus pins number formatting to V8's +// JSON.stringify for edge literals and random finite doubles. +func TestFormatV8NumberAgainstBunCorpus(t *testing.T) { + for _, row := range numberCorpusRows(t) { + literal, want := row[0], row[1] + f, err := strconv.ParseFloat(literal, 64) + if err != nil { + t.Fatalf("corpus literal %q does not parse: %v", literal, err) + } + if got := FormatV8Number(f); got != want { + t.Errorf("FormatV8Number(parse(%s)) = %q, want %q", literal, got, want) + } + } +} + +func quoted(s string) string { return "\"" + s + "\"" } + +// TestEncodeStringMatchesV8Escaping: control characters are escaped, U+2028 and +// U+2029 are emitted literally (encoding/json would escape them), and HTML +// characters are not escaped. +func TestEncodeStringMatchesV8Escaping(t *testing.T) { + lsep := "\u2028" + psep := "\u2029" + cases := []struct { + input string + want string + }{ + {"plain", quoted("plain")}, + {"quote\\backslash", quoted("quote\\\\backslash")}, + {"tab:\tnewline:\ncr:\r", quoted("tab:\\tnewline:\\ncr:\\r")}, + {"control:\x01", quoted("control:\\u0001")}, + {"u2028:" + lsep + "u2029:" + psep, quoted("u2028:" + lsep + "u2029:" + psep)}, + {"html:<>&", quoted("html:<>&")}, + {"\u0000", quoted("\\u0000")}, + {"formfeed:\f", quoted("formfeed:\\f")}, + {"backspace:\b", quoted("backspace:\\b")}, + } + for _, c := range cases { + got, err := EncodeString(c.input) + if err != nil { + t.Fatalf("EncodeString(%q): %v", c.input, err) + } + if string(got) != c.want { + t.Errorf("EncodeString(%q) = %s, want %s", c.input, got, c.want) + } + } +} + +// TestOrderedRoundTripAndSetAppendsAtEnd: document order and spread semantics. +func TestOrderedRoundTripAndSetAppendsAtEnd(t *testing.T) { + root, err := Parse([]byte(`{"b":1,"a":{"x":"y"},"c":[true,null]}`)) + if err != nil { + t.Fatal(err) + } + root.Set("added", StringValue("tail")) + root.Find("a").Set("x", NumberFrom(2)) + encoded, err := root.Encode() + if err != nil { + t.Fatal(err) + } + // b and a keep file order; new top-level key appends at the end; the + // nested x keeps its position and 1.0-shaped literals canonicalise. + if got, want := string(encoded), `{"b":1,"a":{"x":2},"c":[true,null],"added":"tail"}`; got != want { + t.Fatalf("encode = %s, want %s", got, want) + } +} + +// TestEncodeOrdersArrayIndexKeysLikeV8 verifies the own-property order used by +// JSON.stringify: canonical array-index keys sort numerically before ordinary +// string keys, whose relative insertion order remains intact. +func TestEncodeOrdersArrayIndexKeysLikeV8(t *testing.T) { + root, err := Parse([]byte(`{"z":0,"10":"ten","02":"leading","2":"two","4294967294":"last-index","4294967295":"not-index","0":"zero","1e0":"exponent","01":"also-leading","1":"one","nested":{"b":0,"3":3,"0":0,"a":1}}`)) + if err != nil { + t.Fatal(err) + } + root.Set("4", StringValue("four")) + root.Set("after", BoolValue(true)) + + encoded, err := root.Encode() + if err != nil { + t.Fatal(err) + } + want := `{"0":"zero","1":"one","2":"two","4":"four","10":"ten","4294967294":"last-index","z":0,"02":"leading","4294967295":"not-index","1e0":"exponent","01":"also-leading","nested":{"0":0,"3":3,"b":0,"a":1},"after":true}` + if got := string(encoded); got != want { + t.Fatalf("encode = %s, want %s", got, want) + } +} + +func TestArrayIndex(t *testing.T) { + cases := map[string]struct { + value uint32 + ok bool + }{ + "0": {0, true}, + "1": {1, true}, + "4294967294": {4294967294, true}, + "": {0, false}, + "00": {0, false}, + "01": {0, false}, + "-0": {0, false}, + "1.0": {0, false}, + "1e0": {0, false}, + "4294967295": {0, false}, + "4294967296": {0, false}, + } + for key, want := range cases { + got, ok := arrayIndex(key) + if got != want.value || ok != want.ok { + t.Errorf("arrayIndex(%q) = (%d, %t), want (%d, %t)", key, got, ok, want.value, want.ok) + } + } +} + +func TestParseCollapsesDuplicateObjectKeysLikeJSONParse(t *testing.T) { + root, err := Parse([]byte(`{"type":"first","nested":0,"type":"last","nested":1}`)) + if err != nil { + t.Fatal(err) + } + encoded, err := root.Encode() + if err != nil { + t.Fatal(err) + } + if got, want := string(encoded), `{"type":"last","nested":1}`; got != want { + t.Fatalf("duplicate-key encode = %s, want %s", got, want) + } +} + +// TestEncodeCanonicalisesNumbers: a JSON literal is re-emitted the way +// JSON.stringify of the parsed Number would emit it. +func TestEncodeCanonicalisesNumbers(t *testing.T) { + cases := map[string]string{ + `{"n":1.0}`: `{"n":1}`, + `{"n":1e21}`: `{"n":1e+21}`, + `{"n":1e-7}`: `{"n":1e-7}`, + `{"n":0.30000000000000004}`: `{"n":0.30000000000000004}`, + `{"n":9007199254740993}`: `{"n":9007199254740992}`, + } + for input, want := range cases { + root, err := Parse([]byte(input)) + if err != nil { + t.Fatalf("parse %s: %v", input, err) + } + encoded, err := root.Encode() + if err != nil { + t.Fatal(err) + } + if string(encoded) != want { + t.Errorf("encode(%s) = %s, want %s", input, encoded, want) + } + } +} diff --git a/go/internal/jsonwire/pretty.go b/go/internal/jsonwire/pretty.go new file mode 100644 index 0000000000..7f1cdad76c --- /dev/null +++ b/go/internal/jsonwire/pretty.go @@ -0,0 +1,96 @@ +package jsonwire + +import "bytes" + +// EncodePretty emits the value exactly like ECMAScript JSON.stringify(value, +// null, 2): the same compact member/element order and string/number encoding +// as Encode, but with each object member and array element on its own line, +// indented two spaces per nesting level, and `": "` after each object key. +// +// The CLI parity surface needs this because the TypeScript command layer +// reports many management DTOs with console.log(JSON.stringify(x, null, 2)), +// and re-encoding through Go's encoding/json would differ in key order, number +// literals, and string escaping. Empty objects and arrays stay on one line, +// exactly as V8 emits them. +func (v *Value) EncodePretty() ([]byte, error) { + var out bytes.Buffer + if err := v.encodePretty(&out, 0); err != nil { + return nil, err + } + return out.Bytes(), nil +} + +func (v *Value) encodePretty(out *bytes.Buffer, depth int) error { + switch v.kind { + case Null: + out.WriteString("null") + case Bool: + if v.b { + out.WriteString("true") + } else { + out.WriteString("false") + } + case Number: + out.WriteString(v8NumberString(v.num)) + case String: + raw, err := EncodeString(v.str) + if err != nil { + return err + } + out.Write(raw) + case Array: + if len(v.arr) == 0 { + out.WriteString("[]") + return nil + } + out.WriteByte('[') + for i, member := range v.arr { + if i > 0 { + out.WriteByte(',') + } + out.WriteByte('\n') + writePrettyIndent(out, depth+1) + if err := member.encodePretty(out, depth+1); err != nil { + return err + } + } + out.WriteByte('\n') + writePrettyIndent(out, depth) + out.WriteByte(']') + case Object: + members := orderedObjectMembers(v.obj) + if len(members) == 0 { + out.WriteString("{}") + return nil + } + out.WriteByte('{') + for i, member := range members { + if i > 0 { + out.WriteByte(',') + } + out.WriteByte('\n') + writePrettyIndent(out, depth+1) + rawKey, err := EncodeString(member.Key) + if err != nil { + return err + } + out.Write(rawKey) + out.WriteString(": ") + if err := member.Value.encodePretty(out, depth+1); err != nil { + return err + } + } + out.WriteByte('\n') + writePrettyIndent(out, depth) + out.WriteByte('}') + default: + out.WriteString("null") + } + return nil +} + +func writePrettyIndent(out *bytes.Buffer, depth int) { + for i := 0; i < depth; i++ { + out.WriteString(" ") + } +} diff --git a/go/internal/jsonwire/pretty_test.go b/go/internal/jsonwire/pretty_test.go new file mode 100644 index 0000000000..ff1af0e387 --- /dev/null +++ b/go/internal/jsonwire/pretty_test.go @@ -0,0 +1,60 @@ +package jsonwire_test + +import ( + "strings" + "testing" + + "github.com/lidge-jun/opencodex/go/internal/jsonwire" +) + +// TestEncodePrettyMatchesV8 pins EncodePretty against JSON.stringify(value, +// null, 2) as emitted by Node/V8 for the same parsed document: two-space +// indent, each object member and array element on its own line, ": " after +// object keys, inline empty containers, canonical re-encoded numbers, and +// unchanged string escaping. +func TestEncodePrettyMatchesV8(t *testing.T) { + cases := map[string]string{ + `{"a":{"b":1,"c":[1,2,{"x":"y"}],"e":[]},"d":2,"n":1.0,"big":1e21}`: "{\n \"a\": {\n \"b\": 1,\n \"c\": [\n 1,\n 2,\n {\n \"x\": \"y\"\n }\n ],\n \"e\": []\n },\n \"d\": 2,\n \"n\": 1,\n \"big\": 1e+21\n}", + `{"only":{}}`: "{\n \"only\": {}\n}", + `[[],{"z":null}]`: "[\n [],\n {\n \"z\": null\n }\n]", + `{"s":"a\"b\\c\nd\u2028e"}`: "{\n \"s\": \"a\\\"b\\\\c\\nd\u2028e\"\n}", + `{"-0":-0.0,"x":-1.5e-7,"y":0.000001}`: "{\n \"-0\": 0,\n \"x\": -1.5e-7,\n \"y\": 0.000001\n}", + } + for payload, want := range cases { + value, err := jsonwire.Parse([]byte(payload)) + if err != nil { + t.Fatalf("parse %q: %v", payload, err) + } + got, err := value.EncodePretty() + if err != nil { + t.Fatalf("encode %q: %v", payload, err) + } + if string(got) != want { + t.Fatalf("pretty mismatch for %s\n got: %s\nwant: %s", payload, got, want) + } + } +} + +// TestEncodePrettyArrayIndexOrdering confirms pretty output reuses the same +// array-index-first member ordering as compact Encode (V8 own-property order). +func TestEncodePrettyArrayIndexOrdering(t *testing.T) { + payload := `{"b":1,"2":"two","a":3,"10":"ten"}` + value, err := jsonwire.Parse([]byte(payload)) + if err != nil { + t.Fatal(err) + } + got, err := value.EncodePretty() + if err != nil { + t.Fatal(err) + } + compact, err := value.Encode() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "\"2\": \"two\"") || !strings.Contains(string(got), "\"10\": \"ten\"") { + t.Fatalf("pretty output lost array-index ordering: %s", got) + } + if string(compact) != `{"2":"two","10":"ten","b":1,"a":3}` { + t.Fatalf("compact reference drifted: %s", compact) + } +} diff --git a/go/internal/jsonwire/testdata/v8-numbers.tsv b/go/internal/jsonwire/testdata/v8-numbers.tsv new file mode 100644 index 0000000000..289fe0bac5 --- /dev/null +++ b/go/internal/jsonwire/testdata/v8-numbers.tsv @@ -0,0 +1,447 @@ +0 0 +-0 0 +1 1 +-1 -1 +100 100 +123.456 123.456 +0.5 0.5 +-0.5 -0.5 +1e21 1e+21 +1e20 100000000000000000000 +1e-6 0.000001 +1e-7 1e-7 +0.1 0.1 +0.2 0.2 +0.3 0.3 +0.30000000000000004 0.30000000000000004 +1.5 1.5 +1e3 1000 +1e6 1000000 +1e-5 0.00001 +1e-4 0.0001 +5e-324 5e-324 +1.7976931348623157e308 1.7976931348623157e+308 +2.2250738585072014e-308 2.2250738585072014e-308 +9007199254740991 9007199254740991 +9007199254740992 9007199254740992 +9007199254740993 9007199254740992 +9007199254740994 9007199254740994 +1.0 1 +2.50 2.5 +999999999999999.9 999999999999999.9 +123456789.123456789 123456789.12345679 +0.0000012345 0.0000012345 +1000000000000000000000 1e+21 +100000000000000000000 100000000000000000000 +10000000000000000000 10000000000000000000 +0.000001 0.000001 +0.0000001 1e-7 +1e308 1e+308 +2.5e-321 2.5e-321 +1e-323 1e-323 +123456789012345678901234567890 1.2345678901234568e+29 +-1e21 -1e+21 +-1e-7 -1e-7 +1.5e300 1.5e+300 +3.141592653589793 3.141592653589793 +2.718281828459045 2.718281828459045 +2.2024798454760163e-73 2.2024798454760163e-73 +-4.912309340218972e-275 -4.912309340218972e-275 +9.115968646074846e+246 9.115968646074846e+246 +8.75679038787647e+131 8.75679038787647e+131 +8.207012699130086e-77 8.207012699130086e-77 +9.922392529464929e-95 9.922392529464929e-95 +-3.6338368042158724e-60 -3.6338368042158724e-60 +1.5030448053330835e+28 1.5030448053330835e+28 +-2.2273085653974596e-168 -2.2273085653974596e-168 +0.0012043432809252838 0.0012043432809252838 +1.1219349103654346e+71 1.1219349103654346e+71 +-9.114063073637106e+51 -9.114063073637106e+51 +2.756536896019681e+261 2.756536896019681e+261 +4.184891306973065e-243 4.184891306973065e-243 +-4.341320983126188e-190 -4.341320983126188e-190 +-6.50291158970043e-215 -6.50291158970043e-215 +5.007814881800844e+197 5.007814881800844e+197 +4.916499914374165e+256 4.916499914374165e+256 +1.3461260449798923e-64 1.3461260449798923e-64 +2.079615616197604e+306 2.079615616197604e+306 +-1.8585730505302776e-26 -1.8585730505302776e-26 +-3.2960312919200765e+37 -3.2960312919200765e+37 +2.277509078700205e-232 2.277509078700205e-232 +-8.841440270920852e-260 -8.841440270920852e-260 +4.0361299889839843e+95 4.0361299889839843e+95 +-6.058442247357491e+287 -6.058442247357491e+287 +-2.4997006320386993e-61 -2.4997006320386993e-61 +-1.8966560541440982e+138 -1.8966560541440982e+138 +1.8747204731346894e-111 1.8747204731346894e-111 +1.6751845456526915e+222 1.6751845456526915e+222 +2.1183108610782434e-296 2.1183108610782434e-296 +-1.0922971804406309e-30 -1.0922971804406309e-30 +6.290529427508976e+299 6.290529427508976e+299 +-3.602539965434893e-230 -3.602539965434893e-230 +-8.241435710587465e+303 -8.241435710587465e+303 +1.1377735079256708e-148 1.1377735079256708e-148 +1.3641368539917763e-27 1.3641368539917763e-27 +3.8573991988482295e-113 3.8573991988482295e-113 +-8.33201830766345e+26 -8.33201830766345e+26 +-6.898070145764595e-158 -6.898070145764595e-158 +254764670472606970000 254764670472606970000 +4.501716026564775e-264 4.501716026564775e-264 +3.461499811672666e+243 3.461499811672666e+243 +-1.0023447021062338e+152 -1.0023447021062338e+152 +7.052956829209223e-271 7.052956829209223e-271 +-8.294797812664573e+229 -8.294797812664573e+229 +-3.239431352047815e+82 -3.239431352047815e+82 +-5.064507184468739e-269 -5.064507184468739e-269 +8.858526778467132e+269 8.858526778467132e+269 +9.650912236652307e+95 9.650912236652307e+95 +-1.4520360078142536e+278 -1.4520360078142536e+278 +9.073716642059195e+280 9.073716642059195e+280 +-4.4592347756255414e+178 -4.4592347756255414e+178 +4.463979676951595e+69 4.463979676951595e+69 +-7.642382615102823e-142 -7.642382615102823e-142 +-1.4613776262059474e-194 -1.4613776262059474e-194 +-1.4950470737895676e+119 -1.4950470737895676e+119 +-7.963533961656298e+211 -7.963533961656298e+211 +1.5379282433100384e-263 1.5379282433100384e-263 +-4.9432907373459567e-172 -4.9432907373459567e-172 +2.2558714355429195e+146 2.2558714355429195e+146 +-4.887018497109197e-257 -4.887018497109197e-257 +-3.813328923330931e-105 -3.813328923330931e-105 +4.464960110899884e+296 4.464960110899884e+296 +-1.980312778737264e+195 -1.980312778737264e+195 +-1.86626697380911e+219 -1.86626697380911e+219 +2.3327852298231083e+232 2.3327852298231083e+232 +1.6020336778646793e-251 1.6020336778646793e-251 +-3.46626905799688e-104 -3.46626905799688e-104 +1.775015091493092e-7 1.775015091493092e-7 +4.364075408890865e-285 4.364075408890865e-285 +9.221051892455949e+35 9.221051892455949e+35 +-1.2569657321818035e-18 -1.2569657321818035e-18 +-3.388833388060632e-286 -3.388833388060632e-286 +-1.5867651857225797e-198 -1.5867651857225797e-198 +-1.0963847865893509e-178 -1.0963847865893509e-178 +4.8047070396649517e-132 4.8047070396649517e-132 +2.2717866737123957e-139 2.2717866737123957e-139 +9.721044829888433e+267 9.721044829888433e+267 +-1.211067987002467e-104 -1.211067987002467e-104 +1.74821145155455e-134 1.74821145155455e-134 +7.062480694629245e-168 7.062480694629245e-168 +-1.2555398864202485e+68 -1.2555398864202485e+68 +-2.200623579443945e+289 -2.200623579443945e+289 +8.155817226049644e+138 8.155817226049644e+138 +2.1959589466415047e+114 2.1959589466415047e+114 +2.212270531608757e-86 2.212270531608757e-86 +-8.642420676079306e+159 -8.642420676079306e+159 +8.435959563925907e+159 8.435959563925907e+159 +-1.4591311450619044e-259 -1.4591311450619044e-259 +5.76272768485012e-245 5.76272768485012e-245 +-1.2163147861640373e-124 -1.2163147861640373e-124 +-1.759389655365446e-31 -1.759389655365446e-31 +9.98772402050207e-113 9.98772402050207e-113 +5.2389351802602815e-82 5.2389351802602815e-82 +7.871599271944246e-83 7.871599271944246e-83 +-2.0164093368413602e+195 -2.0164093368413602e+195 +2.282147172214799e+276 2.282147172214799e+276 +-2.283212215696564e+242 -2.283212215696564e+242 +53725.47721916314 53725.47721916314 +1.3066545516736248e-92 1.3066545516736248e-92 +1.2953326935266483e+32 1.2953326935266483e+32 +4.805396578168018e-164 4.805396578168018e-164 +-4.991570879015836e-238 -4.991570879015836e-238 +3.9360237266514494e-291 3.9360237266514494e-291 +-3.4207676849738367e-233 -3.4207676849738367e-233 +-8.048690619618798e+211 -8.048690619618798e+211 +-1.3411630451897607e-90 -1.3411630451897607e-90 +3.118329442202555e-208 3.118329442202555e-208 +-7.70768343074788e-48 -7.70768343074788e-48 +-1.7332981327299836e+241 -1.7332981327299836e+241 +2.3024696710032728e+282 2.3024696710032728e+282 +4.9382776150941217e-110 4.9382776150941217e-110 +-9.138897694190638e+165 -9.138897694190638e+165 +2.50326221159602e+229 2.50326221159602e+229 +4.0240593818436033e+278 4.0240593818436033e+278 +-4.320427616935318e+46 -4.320427616935318e+46 +-9.611512091327103e+271 -9.611512091327103e+271 +9.562368595966595e+61 9.562368595966595e+61 +-1.8852173546801614e+115 -1.8852173546801614e+115 +-2.330993123563127e-70 -2.330993123563127e-70 +-2.72213547957552e-280 -2.72213547957552e-280 +3.7346793679159515e-202 3.7346793679159515e-202 +3.6656221040489136e-135 3.6656221040489136e-135 +-5.301589870828967e-202 -5.301589870828967e-202 +-5.852055309302066e+217 -5.852055309302066e+217 +7.16873469811016e+92 7.16873469811016e+92 +2.1228304522858214e-95 2.1228304522858214e-95 +2.507359219310591e-269 2.507359219310591e-269 +7.758280659590552e+161 7.758280659590552e+161 +5.5485174284940524e-204 5.5485174284940524e-204 +-8.105827067960902e+238 -8.105827067960902e+238 +3.3158366065067527e+197 3.3158366065067527e+197 +-6.197081703982123e-227 -6.197081703982123e-227 +8.070899887707712e-233 8.070899887707712e-233 +-2.2044557736286792e+142 -2.2044557736286792e+142 +3.855225922883723e-82 3.855225922883723e-82 +6.822031043255369e+51 6.822031043255369e+51 +282582300623716700 282582300623716700 +-4.7111514143060844e+101 -4.7111514143060844e+101 +-1.1694919292290998e+105 -1.1694919292290998e+105 +7.361870495617792e-81 7.361870495617792e-81 +-1.0339545271944475e+30 -1.0339545271944475e+30 +2.1019243380665186e-135 2.1019243380665186e-135 +-1.6943585013040347e-214 -1.6943585013040347e-214 +-3.7422396414100036e-87 -3.7422396414100036e-87 +-9.192316504088813e-92 -9.192316504088813e-92 +-7.857437779061423e+238 -7.857437779061423e+238 +3.5089984799238676e+65 3.5089984799238676e+65 +-6.089447385334911e-8 -6.089447385334911e-8 +1.1013149986440522e+281 1.1013149986440522e+281 +-2.581031402442362e+294 -2.581031402442362e+294 +-2.5203456009363745e+243 -2.5203456009363745e+243 +-6.811495283320127e+84 -6.811495283320127e+84 +-151558348002103500000 -151558348002103500000 +-8.387577300920048e+297 -8.387577300920048e+297 +2.828451057979873e+255 2.828451057979873e+255 +8.500583400519486e-216 8.500583400519486e-216 +8.045206220480121e+226 8.045206220480121e+226 +-1.5467019039774866e-209 -1.5467019039774866e-209 +-9.159944474577739e-212 -9.159944474577739e-212 +-2.6120376252019643e-300 -2.6120376252019643e-300 +7.469008472851905e-87 7.469008472851905e-87 +2.4094093892018385e+158 2.4094093892018385e+158 +-8.922298645535766e+27 -8.922298645535766e+27 +-58711226.49813076 -58711226.49813076 +-4.09350803461442e-285 -4.09350803461442e-285 +1.7033962956024192e+156 1.7033962956024192e+156 +-1.5166717835654e+282 -1.5166717835654e+282 +2.7224677940593627e-177 2.7224677940593627e-177 +2.684619529634084e-56 2.684619529634084e-56 +1.0988593125521262e-219 1.0988593125521262e-219 +-6.938644166474025e+210 -6.938644166474025e+210 +-2.6364008577465396e+293 -2.6364008577465396e+293 +8.178302618932382e+241 8.178302618932382e+241 +1.378641145340192e-153 1.378641145340192e-153 +-2.2678124133326043e-59 -2.2678124133326043e-59 +4.6931226493081855e+115 4.6931226493081855e+115 +-2.787726031266358e+281 -2.787726031266358e+281 +-1.4469799425466414e-271 -1.4469799425466414e-271 +-2.9799263194427575e-15 -2.9799263194427575e-15 +-2.6317754673882573e-113 -2.6317754673882573e-113 +2.893844335276201e-245 2.893844335276201e-245 +1.0622982378106384e-298 1.0622982378106384e-298 +-3.5156035793378084e-303 -3.5156035793378084e-303 +-3.617803812771562e-278 -3.617803812771562e-278 +-2.113475266205609e+203 -2.113475266205609e+203 +2.3928647596901155e-47 2.3928647596901155e-47 +5.401777213831097e+41 5.401777213831097e+41 +-1.7543232000511543e+46 -1.7543232000511543e+46 +1.240254809462422e-175 1.240254809462422e-175 +4.3155782336610127e-244 4.3155782336610127e-244 +6.298132032438264e+299 6.298132032438264e+299 +-3.352345334019205e-65 -3.352345334019205e-65 +4.3190153797038705e-100 4.3190153797038705e-100 +-6.692415920940391e+166 -6.692415920940391e+166 +-0.0013700632220095016 -0.0013700632220095016 +-4.947662003446558e-48 -4.947662003446558e-48 +-2.6409883664091164e-238 -2.6409883664091164e-238 +-1.8071851890618847e-74 -1.8071851890618847e-74 +-1.8508805977350793e-232 -1.8508805977350793e-232 +4.388644061964233e+229 4.388644061964233e+229 +-7.231565549044985e+273 -7.231565549044985e+273 +-2.0244074382986373e-231 -2.0244074382986373e-231 +131160681633064750 131160681633064750 +-2.5155320689661464e+54 -2.5155320689661464e+54 +-1.8388565625279378e+154 -1.8388565625279378e+154 +-8.151433797629803e+200 -8.151433797629803e+200 +-2.5937841480887814e-290 -2.5937841480887814e-290 +7.706457739883967e+282 7.706457739883967e+282 +2.0962734564568772e+238 2.0962734564568772e+238 +9.910535349901511e-81 9.910535349901511e-81 +4.7157711504244576e+69 4.7157711504244576e+69 +-2.7402894462790804e-46 -2.7402894462790804e-46 +-9.827114711663551e+30 -9.827114711663551e+30 +3.554220677695545e-117 3.554220677695545e-117 +-3.4146963679845868e+106 -3.4146963679845868e+106 +5.318962862361985e-201 5.318962862361985e-201 +9.871402835410877e+121 9.871402835410877e+121 +1.1412348905629192e-17 1.1412348905629192e-17 +-3.1139246672401324e+268 -3.1139246672401324e+268 +-2.609003960593096e+189 -2.609003960593096e+189 +2.8365745257288946e-158 2.8365745257288946e-158 +5.3067422242857325e-257 5.3067422242857325e-257 +-5.859504740916972e-271 -5.859504740916972e-271 +2.2492147959687209e-7 2.2492147959687209e-7 +-4.904450879773971e+177 -4.904450879773971e+177 +-5.522285885085643e-211 -5.522285885085643e-211 +5.944098433468443e+58 5.944098433468443e+58 +5.287382825749597e-240 5.287382825749597e-240 +-2.730163728266833e+21 -2.730163728266833e+21 +7.34635890696579e+251 7.34635890696579e+251 +-2.361071505909628e+148 -2.361071505909628e+148 +-1.663767637436467e-173 -1.663767637436467e-173 +-1.7627621492330367e-100 -1.7627621492330367e-100 +-1.7726267448112727e+141 -1.7726267448112727e+141 +2.0963031384791104e+68 2.0963031384791104e+68 +1.3981225815730525e+97 1.3981225815730525e+97 +-0.0003511340184223033 -0.0003511340184223033 +-4.0203244551125314e+135 -4.0203244551125314e+135 +5.528323271543244e+96 5.528323271543244e+96 +-3.7831200314779335e+28 -3.7831200314779335e+28 +-7.677224984949491e-139 -7.677224984949491e-139 +1.7752143345314225e-153 1.7752143345314225e-153 +2.079849830512014e+146 2.079849830512014e+146 +-1.0649253909507246e-85 -1.0649253909507246e-85 +-1.698505363937437e+137 -1.698505363937437e+137 +-3.5238785336410245e+186 -3.5238785336410245e+186 +-4.7774833687480425e+66 -4.7774833687480425e+66 +-3.2648060987182744e-136 -3.2648060987182744e-136 +-9.675481075195783e-283 -9.675481075195783e-283 +2.8685391782610767e-297 2.8685391782610767e-297 +-7.830504195020289e-185 -7.830504195020289e-185 +7.15091583539001e-96 7.15091583539001e-96 +2.1449747822329523e+158 2.1449747822329523e+158 +1.3840745820148845e-224 1.3840745820148845e-224 +3.386667685000794e+164 3.386667685000794e+164 +-3.450521625473726e+78 -3.450521625473726e+78 +-1.2056736432887659e+191 -1.2056736432887659e+191 +-7707066905953.103 -7707066905953.103 +5.017409549258984e+51 5.017409549258984e+51 +-7.497547297577419e+120 -7.497547297577419e+120 +3.7553015979501766e+159 3.7553015979501766e+159 +1.2052405209697723e-43 1.2052405209697723e-43 +1.8094724146091532e+141 1.8094724146091532e+141 +-1.132385557860936e+21 -1.132385557860936e+21 +2.6348819330578877e-49 2.6348819330578877e-49 +2.9026231655232525e-252 2.9026231655232525e-252 +2.2844945428281225e+188 2.2844945428281225e+188 +-1.429555465011702e-210 -1.429555465011702e-210 +6.996268847778755e-68 6.996268847778755e-68 +-1.0873667179522438e-77 -1.0873667179522438e-77 +-1.3112478871856078e+178 -1.3112478871856078e+178 +-1.2543688823172222e+224 -1.2543688823172222e+224 +5.703312985821721e+105 5.703312985821721e+105 +1.507157503764499e-261 1.507157503764499e-261 +-1.2446308848016212e+62 -1.2446308848016212e+62 +-1.5461434970587543e+269 -1.5461434970587543e+269 +-6.091181953030231e+51 -6.091181953030231e+51 +-3.8743477942147977e+80 -3.8743477942147977e+80 +-1.0828121959261623e-271 -1.0828121959261623e-271 +-3.66632170832722e-117 -3.66632170832722e-117 +1.2191942888971858e-16 1.2191942888971858e-16 +2.4848069877552138e-27 2.4848069877552138e-27 +-1.585654148848525e+124 -1.585654148848525e+124 +-2.974227328000673e+87 -2.974227328000673e+87 +-8.142958132608413e+113 -8.142958132608413e+113 +1.0767616882577613e+277 1.0767616882577613e+277 +-4.886984147298285e-194 -4.886984147298285e-194 +-2.4963718596004362e-229 -2.4963718596004362e-229 +5.514570290486643e+292 5.514570290486643e+292 +3.6619200473683734e-20 3.6619200473683734e-20 +-1.0703086575601078e-211 -1.0703086575601078e-211 +9.057129337961223e+261 9.057129337961223e+261 +-3.166063060545819e+194 -3.166063060545819e+194 +-5.997298919156919e-161 -5.997298919156919e-161 +3.2457370351687516e-17 3.2457370351687516e-17 +-3.5266330213394023e-159 -3.5266330213394023e-159 +-5.5791033568319e+91 -5.5791033568319e+91 +4.439532144255031e-43 4.439532144255031e-43 +-1.4300306081185254e+261 -1.4300306081185254e+261 +9.224778198508854e-275 9.224778198508854e-275 +-3.8246058316146946e+237 -3.8246058316146946e+237 +-1.2014991654531094e-223 -1.2014991654531094e-223 +3.0979581038044984e-111 3.0979581038044984e-111 +2.6160258944737038e-291 2.6160258944737038e-291 +7.806602517684723e+158 7.806602517684723e+158 +1.8912880573096724e-85 1.8912880573096724e-85 +7.087898307086414e-123 7.087898307086414e-123 +-3.7969916607409635e+210 -3.7969916607409635e+210 +8.443146731548927e-21 8.443146731548927e-21 +-6.104954769872035e-10 -6.104954769872035e-10 +-2.1989844938741036e+154 -2.1989844938741036e+154 +7.353796715591475e-20 7.353796715591475e-20 +1.3187696273916067e-25 1.3187696273916067e-25 +1.164307194509587e-264 1.164307194509587e-264 +8.349495480932397e-308 8.349495480932397e-308 +-9.258581262766522e+251 -9.258581262766522e+251 +4.6234113780515647e+142 4.6234113780515647e+142 +1.8945188953861442e+145 1.8945188953861442e+145 +5.322531727952396e-46 5.322531727952396e-46 +-2.9399841684627e+48 -2.9399841684627e+48 +-7.0230703307239625e+280 -7.0230703307239625e+280 +1.3941349305053995e-273 1.3941349305053995e-273 +6.350037142543304e-29 6.350037142543304e-29 +-1.6249683535939513e+185 -1.6249683535939513e+185 +-4.726960026561967e-307 -4.726960026561967e-307 +1.9986620295855155e-160 1.9986620295855155e-160 +-1.5922609312335223e+137 -1.5922609312335223e+137 +-4.2315826357065187e+282 -4.2315826357065187e+282 +2.6177574325462284e-42 2.6177574325462284e-42 +7.36806483957801e-165 7.36806483957801e-165 +-1.43875771187933e+131 -1.43875771187933e+131 +-1.1326630508184948e-178 -1.1326630508184948e-178 +-7.676900049439047e-169 -7.676900049439047e-169 +-1.5577961498751442e-46 -1.5577961498751442e-46 +5.632896202530457e+161 5.632896202530457e+161 +-5.015764269816469e-231 -5.015764269816469e-231 +-430045426644470.06 -430045426644470.06 +2.1854724239024274e+77 2.1854724239024274e+77 +-6.750748166555277e-53 -6.750748166555277e-53 +3.268912253725717e-224 3.268912253725717e-224 +1.1611612234837974e+241 1.1611612234837974e+241 +6.500159003425045e+275 6.500159003425045e+275 +5.526473562254428e-268 5.526473562254428e-268 +-9.553307208171237e+60 -9.553307208171237e+60 +520320215.59967333 520320215.59967333 +-1.5450936288330776e+53 -1.5450936288330776e+53 +-2.134817825448733e-294 -2.134817825448733e-294 +-2.8454314294167886e+285 -2.8454314294167886e+285 +1.045043888132744e+32 1.045043888132744e+32 +2.5137980719579063e+120 2.5137980719579063e+120 +-1.9370984691012275e+43 -1.9370984691012275e+43 +-8.328248524404559e-80 -8.328248524404559e-80 +9.857207957637762e+244 9.857207957637762e+244 +1.0761966140044231e-125 1.0761966140044231e-125 +-5.1131467294321724e+207 -5.1131467294321724e+207 +-1.4661474995361762e-199 -1.4661474995361762e-199 +3.6471360870671885e+241 3.6471360870671885e+241 +4.4113660697082275e+189 4.4113660697082275e+189 +1.9513154973296528e+208 1.9513154973296528e+208 +-4.359652170047144e-253 -4.359652170047144e-253 +-2.445917969109003e-31 -2.445917969109003e-31 +3.4495625246284395e+92 3.4495625246284395e+92 +-3.325668420896801e+55 -3.325668420896801e+55 +-1.311313280852022e-166 -1.311313280852022e-166 +1.1578854311655213e-222 1.1578854311655213e-222 +2.2826502701400065e-119 2.2826502701400065e-119 +-2.2912875412098947e-85 -2.2912875412098947e-85 +-1.1402784420908204e+92 -1.1402784420908204e+92 +-7.99863326029472e+271 -7.99863326029472e+271 +-4.2661562114967973e-178 -4.2661562114967973e-178 +4.985749616362895e-298 4.985749616362895e-298 +-2.320633632608233e-102 -2.320633632608233e-102 +2.259223612842035e+181 2.259223612842035e+181 +-5.016026223669691e-283 -5.016026223669691e-283 +-3.818826543299911e+198 -3.818826543299911e+198 +1.5977189604917555e+224 1.5977189604917555e+224 +-5.541927275684575e+98 -5.541927275684575e+98 +-6.1347684033231716e+231 -6.1347684033231716e+231 +-5.222765945236173e-214 -5.222765945236173e-214 +1.4724592097001444e-125 1.4724592097001444e-125 +6.367852728769269e-272 6.367852728769269e-272 +-2.5036678981699013e+189 -2.5036678981699013e+189 +1.8137927902209716e-234 1.8137927902209716e-234 +-3.115802424623834e+25 -3.115802424623834e+25 +3.4227346966943447e-153 3.4227346966943447e-153 +2.3881697291031773e+234 2.3881697291031773e+234 +6.920287532122495e+29 6.920287532122495e+29 +1.3062861899016102e-208 1.3062861899016102e-208 +-4.64618509775119e+266 -4.64618509775119e+266 +-4.358306852409563e+268 -4.358306852409563e+268 +7.41346120219763e-303 7.41346120219763e-303 +-1.878411247728415e+39 -1.878411247728415e+39 +-1.3262042108217131e+308 -1.3262042108217131e+308 +-1.5281321616291197e-216 -1.5281321616291197e-216 +-2.0611906035584063e-304 -2.0611906035584063e-304 +-9.994921169058123e+149 -9.994921169058123e+149 +1.9540239206243955e-131 1.9540239206243955e-131 +2.935823704307452e-218 2.935823704307452e-218 diff --git a/go/internal/labactivation/activation.go b/go/internal/labactivation/activation.go new file mode 100644 index 0000000000..c5380b6aa8 --- /dev/null +++ b/go/internal/labactivation/activation.go @@ -0,0 +1,140 @@ +// Package labactivation reproduces the Compatibility Lab opt-in activation +// gate (ADR-0008, ticket #19). It mirrors src/lib/lab-activation.ts: an +// install "uses" the Lab when any routing profile exists in config.json OR Lab +// automation is enabled on disk under /lab/automation-config.json +// (with automation-policy.json as the legacy fallback). The TypeScript +// composition root calls labActivationRequired before it activates Lab; the +// equivalent Go decision must answer identically from the same on-disk state, +// which is what the differential oracle (tests/go-lab-gate-parity.test.ts) +// proves against fixture directories. +// +// This package is the seam, not the Lab: it imports no Lab content. The +// provider-slot seam lives in go/internal/routing/compatibility, and nothing +// in the module tree except a future composition root (the flip, ticket #41) +// or a test registers an evidence provider through it. Until the Lab batch +// (ticket #33) lands real Go Lab content, "a no-Lab user executes no Lab code +// in Go" holds because no Go package imports Lab content at all, and it stays +// machine-checkable by the absence of any importer of this package outside +// tests (go list -deps ./... shows only the sidecar's own read-route core, +// which imports neither this package nor the slot). +package labactivation + +import ( + "encoding/json" + "os" + "path/filepath" + + "github.com/lidge-jun/opencodex/go/internal/config" + "github.com/lidge-jun/opencodex/go/internal/routing/compatibility" +) + +// AutomationConfigFile is the current automation authority, sibling of the +// legacy policy file under /lab/. Mirrors the combined path the TS +// side reads first (dirname of the legacy path joined with +// "automation-config.json"). +const ( + automationConfigFile = "automation-config.json" + automationPolicyFile = "automation-policy.json" +) + +// readJSONObject returns the decoded top-level JSON value of path, or nil when +// the file is absent or not a single valid JSON object. Mirrors +// readJsonIfPresent in src/lib/lab-activation.ts: the detector must never +// throw and must never import Lab persistence to answer the question. +func readJSONValue(path string) (any, bool) { + file, err := os.Open(path) + if err != nil { + return nil, false + } + defer file.Close() + decoder := json.NewDecoder(file) + var value any + if err := decoder.Decode(&value); err != nil { + return nil, false + } + return value, true +} + +// policyEnabled extracts policy.enabled from a decoded object. Mirrors the TS +// shape navigation: the combined file carries {policy: {enabled}}. +func policyEnabled(value any) (bool, bool) { + object, ok := value.(map[string]any) + if !ok { + return false, false + } + policy, ok := object["policy"].(map[string]any) + if !ok { + return false, false + } + enabled, ok := policy["enabled"].(bool) + return enabled, ok +} + +// legacyEnabled extracts enabled from the legacy automation-policy.json root. +func legacyEnabled(value any) (bool, bool) { + object, ok := value.(map[string]any) + if !ok { + return false, false + } + enabled, ok := object["enabled"].(bool) + return enabled, ok +} + +// AutomationEnabledOnDisk mirrors labAutomationEnabledOnDisk. Precedence is +// deliberate: the combined automation-config.json is the current authority +// (with policy.enabled present, even false, it decides); only when the +// combined file is absent or carries no policy object does the legacy +// automation-policy.json answer. +func AutomationEnabledOnDisk(configDir string) bool { + legacyPath := filepath.Join(configDir, "lab", automationPolicyFile) + if combined, ok := readJSONValue(filepath.Join(filepath.Dir(legacyPath), automationConfigFile)); ok { + if enabled, decided := policyEnabled(combined); decided { + return enabled + } + } + if legacy, ok := readJSONValue(legacyPath); ok { + if enabled, decided := legacyEnabled(legacy); decided { + return enabled + } + } + return false +} + +// ProfilesRequireActivation mirrors `Object.keys(config.routingProfiles ?? +// {}).length > 0`: the raw routingProfiles value from config.json requires +// activation exactly when it is an object with at least one key. Any other +// shape (absent, null, array, empty object) does not. +func ProfilesRequireActivation(routingProfiles any) bool { + profiles, ok := routingProfiles.(map[string]any) + return ok && len(profiles) > 0 +} + +// Required mirrors labActivationRequired: any routing profile, or automation +// enabled on disk. configFile is the parsed config.json (see internal/config); +// configDir is the directory the TS process resolved as its config home. +func Required(cfg *config.Config, configDir string) bool { + if cfg == nil { + return AutomationEnabledOnDisk(configDir) + } + if ProfilesRequireActivation(cfg.Raw["routingProfiles"]) { + return true + } + return AutomationEnabledOnDisk(configDir) +} + +// Activate performs the activation side of the seam: when required, it +// installs the evidence provider into the core slot; when not required it +// performs no registration and the slot stays nil. It reports whether +// activation happened. This mirrors the TypeScript composition root, which +// calls activateLab only when labActivationRequired is true — the synchronous, +// gap-free guarantee the owner decision (devlog 010) requires the Go side to +// reproduce. The real Lab evidence provider arrives with ticket #33; until +// then the caller (a test, or the flip composition root) supplies one, so the +// seam contract is proven without pretending Lab content exists. +func Activate(slot *compatibility.Slot, cfg *config.Config, configDir string, provider compatibility.EvidenceProvider) bool { + if !Required(cfg, configDir) { + return false + } + slot.Set(provider) + return true +} diff --git a/go/internal/labactivation/activation_test.go b/go/internal/labactivation/activation_test.go new file mode 100644 index 0000000000..fbd34817f0 --- /dev/null +++ b/go/internal/labactivation/activation_test.go @@ -0,0 +1,201 @@ +package labactivation + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/lidge-jun/opencodex/go/internal/config" + "github.com/lidge-jun/opencodex/go/internal/routing/compatibility" +) + +func writeConfig(t *testing.T, dir, content string) *config.Config { + t.Helper() + if content == "" { + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + return &config.Config{Raw: map[string]any{}} + } + path := filepath.Join(dir, "config.json") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := config.LoadFromDir(dir) + if err != nil { + t.Fatalf("fixture config did not load: %v", err) + } + return cfg +} + +func writeLabAutomation(t *testing.T, dir, file, content string) { + t.Helper() + labDir := filepath.Join(dir, "lab") + if err := os.MkdirAll(labDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(labDir, file), []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestProfilesRequireActivation(t *testing.T) { + if ProfilesRequireActivation(nil) { + t.Fatal("absent routingProfiles must not require activation") + } + if ProfilesRequireActivation(map[string]any{}) { + t.Fatal("empty routingProfiles must not require activation") + } + if !ProfilesRequireActivation(map[string]any{"demo": map[string]any{"candidates": []any{}}}) { + t.Fatal("non-empty routingProfiles must require activation") + } + if ProfilesRequireActivation([]any{}) { + t.Fatal("an array routingProfiles must not count (zod rejects it as invalid config)") + } +} + +func TestAutomationEnabledOnDisk(t *testing.T) { + + t.Run("no files means not enabled", func(t *testing.T) { + if AutomationEnabledOnDisk(t.TempDir()) { + t.Fatal("empty config dir must not enable automation") + } + }) + + t.Run("legacy policy file enables", func(t *testing.T) { + sub := t.TempDir() + writeLabAutomation(t, sub, automationPolicyFile, `{"enabled": true}`) + if !AutomationEnabledOnDisk(sub) { + t.Fatal("legacy automation-policy.json with enabled true must enable automation") + } + }) + + t.Run("legacy policy disabled stays off", func(t *testing.T) { + sub := t.TempDir() + writeLabAutomation(t, sub, automationPolicyFile, `{"enabled": false}`) + if AutomationEnabledOnDisk(sub) { + t.Fatal("legacy policy with enabled false must not enable automation") + } + }) + + t.Run("combined file is the authority", func(t *testing.T) { + sub := t.TempDir() + writeLabAutomation(t, sub, automationConfigFile, `{"policy": {"enabled": true}}`) + writeLabAutomation(t, sub, automationPolicyFile, `{"enabled": false}`) + if !AutomationEnabledOnDisk(sub) { + t.Fatal("combined automation-config.json must win over the legacy file") + } + }) + + t.Run("combined enabled false decides even when legacy says true", func(t *testing.T) { + sub := t.TempDir() + writeLabAutomation(t, sub, automationConfigFile, `{"policy": {"enabled": false}}`) + writeLabAutomation(t, sub, automationPolicyFile, `{"enabled": true}`) + if AutomationEnabledOnDisk(sub) { + t.Fatal("combined enabled false must decide (the current dashboard can turn automation off)") + } + }) + + t.Run("malformed files mean not enabled", func(t *testing.T) { + sub := t.TempDir() + writeLabAutomation(t, sub, automationConfigFile, `{not json`) + writeLabAutomation(t, sub, automationPolicyFile, `{"enabled": "yes"}`) + if AutomationEnabledOnDisk(sub) { + t.Fatal("malformed or wrong-typed automation files must not enable automation") + } + }) + + t.Run("combined without a policy object falls back to legacy", func(t *testing.T) { + sub := t.TempDir() + writeLabAutomation(t, sub, automationConfigFile, `{"scheduler": {}}`) + writeLabAutomation(t, sub, automationPolicyFile, `{"enabled": true}`) + if !AutomationEnabledOnDisk(sub) { + t.Fatal("combined file without a policy object must fall back to the legacy file") + } + }) +} + +func TestRequired(t *testing.T) { + dir := t.TempDir() + if Required(writeConfig(t, dir, `{}`), dir) { + t.Fatal("empty config must not require Lab activation") + } + + profilesDir := t.TempDir() + profilesJSON := `{"routingProfiles": {"demo": {"candidates": [{"provider": "openai", "model": "gpt-5.5"}]}}}` + writeConfig(t, profilesDir, profilesJSON) + if !Required(writeConfig(t, profilesDir, profilesJSON), profilesDir) { + t.Fatal("a routing profile must require Lab activation") + } + if cfg, err := config.LoadFromDir(profilesDir); err != nil || !Required(cfg, profilesDir) { + t.Fatal("parsed config with routingProfiles must require Lab activation") + } + + autoDir := t.TempDir() + writeLabAutomation(t, autoDir, automationConfigFile, `{"policy": {"enabled": true}}`) + if !Required(writeConfig(t, autoDir, `{}`), autoDir) { + t.Fatal("automation enabled on disk must require Lab activation") + } + + // A profile plus automation off still requires activation via the profile. + bothDir := t.TempDir() + writeConfig(t, bothDir, profilesJSON) + writeLabAutomation(t, bothDir, automationConfigFile, `{"policy": {"enabled": false}}`) + if cfg, err := config.LoadFromDir(bothDir); err != nil || !Required(cfg, bothDir) { + t.Fatal("a routing profile must require activation even with automation off") + } +} + +func TestActivateRegistersOnlyWhenRequired(t *testing.T) { + slot := compatibility.NewSlot() + provider := func(options compatibility.EvidenceOptions) compatibility.CandidateEvidence { + return compatibility.CandidateEvidence{} + } + + dir := t.TempDir() + writeConfig(t, dir, `{}`) + if activated := Activate(slot, writeConfig(t, dir, `{}`), dir, provider); activated { + t.Fatal("empty install must not activate") + } + if resolved := slot.Resolve(); resolved != nil { + t.Fatal("a non-required install must register nothing (slot stays nil)") + } + + profilesDir := t.TempDir() + writeConfig(t, profilesDir, `{"routingProfiles": {"demo": {"candidates": [{"provider": "openai", "model": "gpt-5.5"}]}}}`) + cfg, err := config.LoadFromDir(profilesDir) + if err != nil { + t.Fatal(err) + } + if !Activate(slot, cfg, profilesDir, provider) { + t.Fatal("a required install must activate") + } + if resolved := slot.Resolve(); resolved == nil { + t.Fatal("activation must register the provider into the core slot") + } + + // The detach returned by Set only removes its own registration. + replacement := func(options compatibility.EvidenceOptions) compatibility.CandidateEvidence { + return nil + } + detach := slot.Set(replacement) + slot.Set(provider) + detach() + if resolved := slot.Resolve(); resolved == nil || funcPointer(resolved) != funcPointer(provider) { + t.Fatal("a stale detach must not remove a newer registration") + } + slot.Reset() + if slot.Resolve() != nil { + t.Fatal("reset must clear the slot") + } +} + +// funcPointer is the identity proxy for comparing function values (Go funcs +// are only comparable to nil). +func funcPointer(fn compatibility.EvidenceProvider) uintptr { + return reflect.ValueOf(fn).Pointer() +} diff --git a/go/internal/managementauth/auth.go b/go/internal/managementauth/auth.go new file mode 100644 index 0000000000..d9528f5909 --- /dev/null +++ b/go/internal/managementauth/auth.go @@ -0,0 +1,80 @@ +// Package managementauth reproduces the TypeScript management admission +// model (ADR-0008, ticket #18: "Go management auth/session model"). +// +// src/server/management-auth.ts decides whether a management request is +// admitted and under which principal: a process-scoped capability +// (system-restart, local-provider-reload, local-read, gui-pair), the admin +// token, or a dashboard session. This package mirrors that decision logic so a +// request answered by Go without the TypeScript front door having already +// admitted it is authorised identically — the acceptance criterion is +// "under-privileged requests rejected identically to TypeScript", and the +// differential oracle (tests/go-auth-parity.test.ts) proves it by running the +// same request vectors through src/server/management-auth.ts and this package +// and comparing the resulting principal-or-rejection byte for byte. +// +// State-source note: the four capability checks are pure functions of their +// inputs (request headers, the local context's attestation secret/pid/port); +// the admin token is env/disk state; the dashboard session table is owned by +// whatever process mints sessions. The TS front door still admits every +// request before forwarding pre-flip (src/server/index.ts), so this gate is +// exercised live only once Go serves management routes without that front door +// (the write batches' state-reset differential and the authorization gate, +// tickets #21-#23/#26). Until then it is substrate, proven by the oracle. +package managementauth + +// Principal mirrors ManagementPrincipal in src/server/management-auth.ts. +type Principal string + +const ( + // PrincipalAdminToken is the raw token from disk/env. + PrincipalAdminToken Principal = "admin-token" + // PrincipalGuiSession is a session token this process minted for a browser. + PrincipalGuiSession Principal = "gui-session" + // PrincipalGuiPairCapability is a process-scoped HMAC for the pairing-grant path. + PrincipalGuiPairCapability Principal = "gui-pair-capability" + // PrincipalLocalReadCapability is a process-scoped HMAC for two allowlisted GETs. + PrincipalLocalReadCapability Principal = "local-read-capability" + // PrincipalLocalProviderReloadCapability is a process-scoped HMAC for the reload POST. + PrincipalLocalProviderReloadCapability Principal = "local-provider-reload-capability" + // PrincipalSystemRestartCapability is a process-scoped HMAC for the restart POST. + PrincipalSystemRestartCapability Principal = "system-restart-capability" +) + +// Session mirrors GuiSessionRecord in src/server/gui-session.ts. +type Session struct { + ServerOrigin string + BrowserOrigin string + CSRF string + ExpiresAt int64 // epoch milliseconds + Issuance string +} + +// State mirrors ManagementAuthState in src/server/management-auth.ts. Sessions +// are owned by the process that mints them; the Go gate carries the table so +// validation mutates it the way the TypeScript side does (expiry deletion, +// remote-session sliding). +type State struct { + Available bool + Token string + Source string // "environment" | "file" + Reason string // set when !Available + Sessions map[string]Session +} + +// LocalContext mirrors LocalManagementAuthContext: the process-scoped +// attestation secret, pid, and listening port that capability HMACs bind to. +type LocalContext struct { + AttestationSecret string + PID int + Port int +} + +// ConfigView is the slice of OcxConfig that the admission logic reads +// (config.hostname via isApiAuthRequired, runtimeRole and +// hub.managementPublicOrigin via managementRequestOrigin). Everything else is +// irrelevant to validation; see src/server/auth-cors.ts. +type ConfigView struct { + Hostname string + RuntimeRole string + HubManagementPublicOrigin string +} diff --git a/go/internal/managementauth/capability.go b/go/internal/managementauth/capability.go new file mode 100644 index 0000000000..609159ead7 --- /dev/null +++ b/go/internal/managementauth/capability.go @@ -0,0 +1,430 @@ +package managementauth + +// Process-scoped capability contracts (ADR-0008, ticket #18). Each mirrors the +// homonymous module under src/lib/: the payload string that the TypeScript +// side HMACs, the exact header names, the allowlist shapes, and the +// base64url-256 (43-char) secret/capability format. A capability minted by the +// TypeScript process must verify here byte-for-byte, and one minted here must +// verify on the TypeScript side — the differential oracle pins both +// directions. The payloads are versioned strings joined with \n; the trailing +// pieces are exactly as src/lib emits them, so an off-by-one field or an extra +// newline breaks parity immediately. +// +// Header modelling: an empty string means the header is absent. TypeScript +// distinguishes null (absent) from "" only for the pid parsers (absent vs +// invalid) and empty capability/browser-origin values, and every such +// distinction converges on the same admission decision (rejection), so folding +// "" into absent never changes an outcome the oracle can observe. + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "net/url" + "regexp" + "strconv" + "strings" +) + +// Contract header names, paths, methods, and TTLs mirror the TS constants. +const ( + LocalManagementExpectedPIDHeader = "x-opencodex-local-expected-pid" + LocalManagementNonceHeader = "x-opencodex-local-nonce" + LocalManagementExpiresAtHeader = "x-opencodex-local-expires-at" + LocalManagementCapabilityHeader = "x-opencodex-local-capability" + LocalManagementCapabilityTTLMs = 10_000 + + LocalManagementReadPathCodexAccounts = "/api/codex-auth/accounts" + LocalManagementReadPathSystemMemory = "/api/system/memory" + + SystemRestartExpectedPIDHeader = "x-opencodex-restart-expected-pid" + SystemRestartNonceHeader = "x-opencodex-restart-nonce" + SystemRestartCapabilityHeader = "x-opencodex-restart-capability" + SystemRestartMethod = "POST" + SystemRestartPath = "/api/system/restart" + + LocalProviderReloadExpectedPIDHeader = "x-opencodex-provider-reload-expected-pid" + LocalProviderReloadNonceHeader = "x-opencodex-provider-reload-nonce" + LocalProviderReloadExpiresAtHeader = "x-opencodex-provider-reload-expires-at" + LocalProviderReloadNameHeader = "x-opencodex-provider-reload-name" + LocalProviderReloadCapabilityHeader = "x-opencodex-provider-reload-capability" + LocalProviderReloadCapabilityTTLMs = 10_000 + LocalProviderReloadMethod = "POST" + LocalProviderReloadPath = "/api/providers/reload" + + GUIPairExpectedPIDHeader = "x-opencodex-gui-pair-expected-pid" + GUIPairNonceHeader = "x-opencodex-gui-pair-nonce" + GUIPairExpiresAtHeader = "x-opencodex-gui-pair-expires-at" + GUIPairBrowserOriginHeader = "x-opencodex-gui-pair-origin" + GUIPairCapabilityHeader = "x-opencodex-gui-pair-capability" + GUIPairCapabilityTTLMs = 10_000 + GUIPairMethod = "POST" + GUIPairPath = "/api/gui/pairing-grants" + + localReadMethod = "GET" +) + +var ( + // base64URL256 mirrors BASE64URL_256 in the TS contracts: a 256-bit + // base64url string without padding (43 characters). + base64URL256 = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) + // providerNamePattern mirrors PROVIDER_NAME in + // src/lib/local-provider-reload-contract.ts. + providerNamePattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$`) + // positiveDecimal mirrors the pid/expiry parsers: no leading zero, no zero. + positiveDecimal = regexp.MustCompile(`^[1-9]\d*$`) +) + +// IsBase64URL256 reports whether value is a 256-bit base64url string. +func IsBase64URL256(value string) bool { + return base64URL256.MatchString(value) +} + +// IsAttestationSecret mirrors isLocalAttestationSecret. +func IsAttestationSecret(value string) bool { + return base64URL256.MatchString(value) +} + +// IsLocalProviderReloadName mirrors isLocalProviderReloadName. +func IsLocalProviderReloadName(value string) bool { + return providerNamePattern.MatchString(value) +} + +// hmacBase64URL computes HMAC-SHA256 over payload keyed by secret, encoded +// base64url without padding — Node's .digest("base64url") format. +func hmacBase64URL(secret string, payload string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// equalSecretByte is the timing-safe comparison every verify uses, with the +// TS length guard first. +func equalCapabilityBytes(expected, actual string) bool { + if len(expected) != len(actual) { + return false + } + return hmac.Equal([]byte(expected), []byte(actual)) +} + +// ExpectedPIDKind mirrors the kind-union parse in the TS contracts. +type ExpectedPIDKind string + +const ( + ExpectedPIDAbsent ExpectedPIDKind = "absent" + ExpectedPIDInvalid ExpectedPIDKind = "invalid" + ExpectedPIDPresent ExpectedPIDKind = "present" +) + +// ParseExpectedPID mirrors parseExpectedLocalManagementPid and its siblings. +// value == "" models an absent header; anything that is not positive decimal +// digits is invalid. +func ParseExpectedPID(value string) (ExpectedPIDKind, int64) { + if value == "" { + return ExpectedPIDAbsent, 0 + } + if !positiveDecimal.MatchString(value) { + return ExpectedPIDInvalid, 0 + } + parsed, err := parseDecimalInt64(value) + if err != nil { + return ExpectedPIDInvalid, 0 + } + return ExpectedPIDPresent, parsed +} + +// parseExpiryHeader mirrors the TS expiry checks: the raw header must match +// ^[1-9]\d*$ and parse to a safe integer, or the request is rejected before +// any capability verification runs. +func parseExpiryHeader(value string) (int64, bool) { + if !positiveDecimal.MatchString(value) { + return 0, false + } + parsed, err := parseDecimalInt64(value) + if err != nil { + return 0, false + } + return parsed, true +} + +func parseDecimalInt64(value string) (int64, error) { + var out int64 + for _, r := range value { + if r < '0' || r > '9' { + return 0, errNotDecimal + } + next := out*10 + int64(r-'0') + if next < out { + // Overflow cannot be a JS safe integer; do not wrap silently. + return 0, errNotDecimal + } + out = next + } + return out, nil +} + +var errNotDecimal = ¬DecimalError{} + +type notDecimalError struct{} + +func (*notDecimalError) Error() string { return "not a decimal integer" } + +func expiryWithin(now, expiresAt, ttl int64) bool { + // TS: !Number.isSafeInteger(now) rejects; expiresAt <= now rejects; + // expiresAt > now + TTL rejects. + return expiresAt > now && expiresAt <= now+ttl +} + +// --------------------------------------------------------------------------- +// Attestation (src/lib/local-management-attestation.ts) +// --------------------------------------------------------------------------- + +func attestationPayload(challenge string, pid int64, port int) (string, bool) { + if !base64URL256.MatchString(challenge) || pid <= 0 || port <= 0 || port > 65535 { + return "", false + } + return "opencodex-local-management-v1\n" + challenge + "\n" + itoa(pid) + "\n" + itoaInt(port), true +} + +// CreateLocalAttestationProof mirrors createLocalAttestationProof. Empty +// result means the inputs are invalid. +func CreateLocalAttestationProof(secret, challenge string, pid int64, port int) string { + if !IsAttestationSecret(secret) { + return "" + } + payload, ok := attestationPayload(challenge, pid, port) + if !ok { + return "" + } + return hmacBase64URL(secret, payload) +} + +// VerifyLocalAttestationProof mirrors verifyLocalAttestationProof. +func VerifyLocalAttestationProof(secret, challenge string, pid int64, port int, proof string) bool { + expected := CreateLocalAttestationProof(secret, challenge, pid, port) + if expected == "" || !base64URL256.MatchString(proof) { + return false + } + return equalCapabilityBytes(expected, proof) +} + +// --------------------------------------------------------------------------- +// System restart (src/lib/system-restart-contract.ts) +// --------------------------------------------------------------------------- + +func restartPayload(nonce, method, path string, pid int64, port int) (string, bool) { + if !base64URL256.MatchString(nonce) || method != SystemRestartMethod || path != SystemRestartPath || pid <= 0 || port <= 0 || port > 65535 { + return "", false + } + return "opencodex-system-restart-v1\n" + nonce + "\n" + method + "\n" + path + "\n" + itoa(pid) + "\n" + itoaInt(port), true +} + +// CreateSystemRestartCapability mirrors createSystemRestartCapability. +func CreateSystemRestartCapability(secret, nonce, method, path string, pid int64, port int) string { + if !IsAttestationSecret(secret) { + return "" + } + payload, ok := restartPayload(nonce, method, path, pid, port) + if !ok { + return "" + } + return hmacBase64URL(secret, payload) +} + +// VerifySystemRestartCapability mirrors verifySystemRestartCapability. The +// restart contract has no expiry window. +func VerifySystemRestartCapability(secret, nonce, method, path string, pid int64, port int, capability string) bool { + if nonce == "" || !base64URL256.MatchString(capability) { + return false + } + expected := CreateSystemRestartCapability(secret, nonce, method, path, pid, port) + if expected == "" { + return false + } + return equalCapabilityBytes(expected, capability) +} + +// --------------------------------------------------------------------------- +// Local provider reload (src/lib/local-provider-reload-contract.ts) +// --------------------------------------------------------------------------- + +func providerReloadPayload(nonce, method, path, name string, pid int64, port int, expiresAt int64) (string, bool) { + if !base64URL256.MatchString(nonce) || method != LocalProviderReloadMethod || path != LocalProviderReloadPath { + return "", false + } + if !IsLocalProviderReloadName(name) { + return "", false + } + if pid <= 0 || port <= 0 || port > 65535 || expiresAt <= 0 { + return "", false + } + return "opencodex-local-provider-reload-v1\n" + nonce + "\n" + method + "\n" + path + "\n" + name + "\n" + itoa(pid) + "\n" + itoaInt(port) + "\n" + itoa(expiresAt), true +} + +// CreateLocalProviderReloadCapability mirrors createLocalProviderReloadCapability. +func CreateLocalProviderReloadCapability(secret, nonce, method, path, name string, pid int64, port int, expiresAt int64) string { + if !IsAttestationSecret(secret) { + return "" + } + payload, ok := providerReloadPayload(nonce, method, path, name, pid, port, expiresAt) + if !ok { + return "" + } + return hmacBase64URL(secret, payload) +} + +// VerifyLocalProviderReloadCapability mirrors +// verifyLocalProviderReloadCapability. name == "" models an absent name +// header, which fails verification exactly as the TS null does. +func VerifyLocalProviderReloadCapability(secret, nonce, method, path, name string, pid int64, port int, expiresAt int64, capability string, now int64) bool { + if nonce == "" || name == "" || !base64URL256.MatchString(capability) { + return false + } + if !expiryWithin(now, expiresAt, LocalProviderReloadCapabilityTTLMs) { + return false + } + expected := CreateLocalProviderReloadCapability(secret, nonce, method, path, name, pid, port, expiresAt) + if expected == "" { + return false + } + return equalCapabilityBytes(expected, capability) +} + +// --------------------------------------------------------------------------- +// GUI pairing (src/lib/gui-pair-capability.ts) +// --------------------------------------------------------------------------- + +// CanonicalGuiBrowserOrigin mirrors canonicalGuiBrowserOrigin. It returns "" +// for values that do not canonicalise. +func CanonicalGuiBrowserOrigin(value string) string { + if value == "" || strings.TrimSpace(value) != value { + return "" + } + parsed, err := url.Parse(value) + if err != nil { + return "" + } + if parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "" + } + if parsed.Path != "" && parsed.Path != "/" { + return "" + } + if parsed.Scheme == "http" || parsed.Scheme == "https" { + return whatwgOrigin(parsed) + } + return parsed.Scheme + "://" + parsed.Host +} + +func guiPairPayload(nonce, method, path, browserOrigin string, pid int64, port int, expiresAt int64) (string, bool) { + if !base64URL256.MatchString(nonce) || method != GUIPairMethod || path != GUIPairPath { + return "", false + } + canonical := CanonicalGuiBrowserOrigin(browserOrigin) + if canonical == "" || canonical != browserOrigin { + return "", false + } + if pid <= 0 || port <= 0 || port > 65535 || expiresAt <= 0 { + return "", false + } + return "opencodex-gui-pair-v1\n" + nonce + "\n" + method + "\n" + path + "\n" + browserOrigin + "\n" + itoa(pid) + "\n" + itoaInt(port) + "\n" + itoa(expiresAt), true +} + +// CreateGuiPairCapability mirrors createGuiPairCapability. +func CreateGuiPairCapability(secret, nonce, method, path, browserOrigin string, pid int64, port int, expiresAt int64) string { + if !IsAttestationSecret(secret) { + return "" + } + payload, ok := guiPairPayload(nonce, method, path, browserOrigin, pid, port, expiresAt) + if !ok { + return "" + } + return hmacBase64URL(secret, payload) +} + +// VerifyGuiPairCapability mirrors verifyGuiPairCapability. browserOrigin == "" +// models an absent origin header. +func VerifyGuiPairCapability(secret, nonce, method, path, browserOrigin string, pid int64, port int, expiresAt int64, capability string, now int64) bool { + if nonce == "" || browserOrigin == "" || !base64URL256.MatchString(capability) { + return false + } + if !expiryWithin(now, expiresAt, GUIPairCapabilityTTLMs) { + return false + } + expected := CreateGuiPairCapability(secret, nonce, method, path, browserOrigin, pid, port, expiresAt) + if expected == "" { + return false + } + return equalCapabilityBytes(expected, capability) +} + +// --------------------------------------------------------------------------- +// Local management read (src/lib/local-management-capability.ts) +// --------------------------------------------------------------------------- + +func localReadPayload(nonce, method, path string, pid int64, port int, expiresAt int64) (string, bool) { + if !base64URL256.MatchString(nonce) || method != localReadMethod { + return "", false + } + if path != LocalManagementReadPathCodexAccounts && path != LocalManagementReadPathSystemMemory { + return "", false + } + if pid <= 0 || port <= 0 || port > 65535 || expiresAt <= 0 { + return "", false + } + return "opencodex-local-management-read-v1\n" + nonce + "\n" + method + "\n" + path + "\n" + itoa(pid) + "\n" + itoaInt(port) + "\n" + itoa(expiresAt), true +} + +// CreateLocalManagementReadCapability mirrors createLocalManagementReadCapability. +func CreateLocalManagementReadCapability(secret, nonce, method, path string, pid int64, port int, expiresAt int64) string { + if !IsAttestationSecret(secret) { + return "" + } + payload, ok := localReadPayload(nonce, method, path, pid, port, expiresAt) + if !ok { + return "" + } + return hmacBase64URL(secret, payload) +} + +// VerifyLocalManagementReadCapability mirrors +// verifyLocalManagementReadCapability. +func VerifyLocalManagementReadCapability(secret, nonce, method, path string, pid int64, port int, expiresAt int64, capability string, now int64) bool { + if nonce == "" || !base64URL256.MatchString(capability) { + return false + } + if !expiryWithin(now, expiresAt, LocalManagementCapabilityTTLMs) { + return false + } + expected := CreateLocalManagementReadCapability(secret, nonce, method, path, pid, port, expiresAt) + if expected == "" { + return false + } + return equalCapabilityBytes(expected, capability) +} + +// whatwgOrigin reproduces URL.prototype.origin for http/https: scheme plus the +// serialized host, lowercased, with the scheme's default port dropped. IPv6 +// hosts keep their brackets. +func whatwgOrigin(u *url.URL) string { + host := u.Hostname() + if strings.Contains(host, ":") { + host = "[" + host + "]" + } + port := u.Port() + if (u.Scheme == "http" && port == "80") || (u.Scheme == "https" && port == "443") { + port = "" + } + if port != "" { + host += ":" + port + } + return u.Scheme + "://" + strings.ToLower(host) +} + +func itoa(value int64) string { + return strconv.FormatInt(value, 10) +} + +func itoaInt(value int) string { + return strconv.FormatInt(int64(value), 10) +} diff --git a/go/internal/managementauth/gate.go b/go/internal/managementauth/gate.go new file mode 100644 index 0000000000..17aad9b4f4 --- /dev/null +++ b/go/internal/managementauth/gate.go @@ -0,0 +1,451 @@ +package managementauth + +// The admission gate itself (ADR-0008, ticket #18): the decision ordering, +// the dashboard-session authorization, the per-principal replay control, and +// the exact rejection responses. This mirrors resolveManagementAdmission and +// requireManagementAuth in src/server/management-auth.ts plus +// authorizeGuiSessionRequest in src/server/gui-session.ts. + +import ( + "crypto/sha256" + "encoding/base64" + "strings" + "sync" + "time" +) + +// Constants shared with the TS side. +const ( + loopbackGuiSessionTTLMs = 5 * 60_000 + remoteGuiSessionTTLMs = 12 * 60 * 60_000 + + // Replay-store limits mirror the TS module-level constants. + localReadReplayLimit = 256 + providerReloadReplayLimit = 256 + guiPairReplayLimit = 256 + + // Header names the session/credential paths read (fixed lowercase). + xOpenCodexAPIKeyHeader = "x-opencodex-api-key" + authorizationHeader = "authorization" + guiOriginHeader = "x-opencodex-gui-origin" + originHeader = "origin" + csrfHeader = "x-opencodex-csrf-token" + contentLengthHeader = "content-length" + transferEncodingHeader = "transfer-encoding" +) + +// SessionAdmissionReason mirrors the ok:false reasons of GuiSessionAdmission. +type SessionAdmissionReason string + +const ( + SessionMissing SessionAdmissionReason = "missing" + SessionExpired SessionAdmissionReason = "expired" + SessionServerOrigin SessionAdmissionReason = "server-origin" + SessionBrowserOrigin SessionAdmissionReason = "browser-origin" + SessionCSRF SessionAdmissionReason = "csrf" +) + +// SessionAdmission mirrors GuiSessionAdmission. +type SessionAdmission struct { + OK bool + Reason SessionAdmissionReason + Session Session +} + +// AuthorizeSession mirrors authorizeGuiSessionRequest. It may mutate the +// sessions map exactly as the TS side does: an expired session is deleted and +// a remote session's expiry slides forward on success. +func AuthorizeSession(r *Request, cfg ConfigView, sessions map[string]Session, now int64) SessionAdmission { + credential := RequestManagementCredential(r) + if credential == "" { + return SessionAdmission{OK: false, Reason: SessionMissing} + } + token, session, found := findSession(credential, sessions) + if !found { + return SessionAdmission{OK: false, Reason: SessionMissing} + } + if session.ExpiresAt <= now { + delete(sessions, token) + return SessionAdmission{OK: false, Reason: SessionExpired} + } + if ManagementRequestOrigin(r, cfg) != session.ServerOrigin { + return SessionAdmission{OK: false, Reason: SessionServerOrigin} + } + claimedBrowserOrigin := r.Get(guiOriginHeader) + browserOrigin := r.Get(originHeader) + safeMethod := r.Method == "GET" || r.Method == "HEAD" + if claimedBrowserOrigin != session.BrowserOrigin || + (browserOrigin != "" && browserOrigin != session.BrowserOrigin) || + (!safeMethod && browserOrigin != session.BrowserOrigin) { + return SessionAdmission{OK: false, Reason: SessionBrowserOrigin} + } + if !safeMethod { + csrf := strings.TrimSpace(r.Get(csrfHeader)) + if csrf == "" || !EqualSecret(csrf, session.CSRF) { + return SessionAdmission{OK: false, Reason: SessionCSRF} + } + } + if session.Issuance != "loopback" { + session.ExpiresAt = now + remoteGuiSessionTTLMs + sessions[token] = session + } + return SessionAdmission{OK: true, Session: session} +} + +// findSession mirrors findSession: timing-safe token comparison over the +// session table. +func findSession(credential string, sessions map[string]Session) (string, Session, bool) { + for token, session := range sessions { + if EqualSecret(credential, token) { + return token, session, true + } + } + return "", Session{}, false +} + +// Rejection is the exact response requireManagementAuth would return: the +// status and the JSON body bytes, byte-identical to Response.json on the TS +// side (compact JSON, no trailing newline). +type Rejection struct { + Status int + Body string +} + +// Decision is the outcome of one admission check. +type Decision struct { + // Principal is non-empty exactly when the request is admitted. + Principal Principal + // Rejection is non-nil exactly when the request is not admitted. + Rejection *Rejection +} + +// Gate carries the process-scoped admission state and replay stores. One Gate +// serves one process, mirroring the module-level maps in management-auth.ts. +// Methods are safe for concurrent use; the TS side is single-threaded, so the +// mutex only protects the Go process's own concurrency. +type Gate struct { + mu sync.Mutex + state State + cfg ConfigView + local LocalContext + nowFn func() int64 + + consumedLocalRead map[string]int64 + consumedProviderReload map[string]int64 + consumedGuiPair map[string]int64 +} + +// NewGate builds a Gate over the given state, config view, and local context. +func NewGate(state State, cfg ConfigView, local LocalContext) *Gate { + if state.Sessions == nil { + state.Sessions = map[string]Session{} + } + return &Gate{ + state: state, + cfg: cfg, + local: local, + nowFn: time.Now().UnixMilli, + consumedLocalRead: map[string]int64{}, + consumedProviderReload: map[string]int64{}, + consumedGuiPair: map[string]int64{}, + } +} + +// WithClock replaces the wall-clock source (tests only). +func (g *Gate) WithClock(now func() int64) *Gate { + g.nowFn = now + return g +} + +// State returns a copy of the admission state (tests and management routes +// that need to inspect sessions). +func (g *Gate) State() State { + g.mu.Lock() + defer g.mu.Unlock() + out := g.state + out.Sessions = map[string]Session{} + for k, v := range g.state.Sessions { + out.Sessions[k] = v + } + return out +} + +// Sessions exposes the session table for direct manipulation (session routes +// mint and revoke through the TS side pre-flip; the Go side owns it at the +// flip). +func (g *Gate) Sessions() map[string]Session { + g.mu.Lock() + defer g.mu.Unlock() + out := make(map[string]Session, len(g.state.Sessions)) + for k, v := range g.state.Sessions { + out[k] = v + } + return out +} + +// Admit mirrors resolveManagementAdmission plus the rejection mapping of +// requireManagementAuth: capabilities first (they do not need the state to be +// available), then the admin token, then a dashboard session. +func (g *Gate) Admit(r *Request) Decision { + g.mu.Lock() + defer g.mu.Unlock() + now := g.nowFn() + + if g.hasSystemRestartCapability(r) { + return Decision{Principal: PrincipalSystemRestartCapability} + } + if g.hasLocalProviderReloadCapability(r, now) { + return Decision{Principal: PrincipalLocalProviderReloadCapability} + } + if g.hasLocalReadCapability(r, now) { + return Decision{Principal: PrincipalLocalReadCapability} + } + if g.hasGuiPairCapability(r, now) { + return Decision{Principal: PrincipalGuiPairCapability} + } + if g.state.Available { + actual := RequestManagementCredential(r) + if actual != "" && EqualSecret(actual, g.state.Token) { + return Decision{Principal: PrincipalAdminToken} + } + if admission := AuthorizeSession(r, g.cfg, g.state.Sessions, now); admission.OK { + return Decision{Principal: PrincipalGuiSession} + } + } + if !g.state.Available { + return Decision{Rejection: &Rejection{ + Status: 503, + Body: unavailableBody(g.state.Reason), + }} + } + return Decision{Rejection: &Rejection{ + Status: 401, + Body: unauthorizedBody, + }} +} + +// capabilityRequestPath extracts the URL pathname, mirroring new +// URL(req.url).pathname. Empty means unparseable. +func capabilityRequestPath(r *Request) string { + parsed := r.parsedURL() + if parsed == nil { + return "" + } + return parsed.Path +} + +func hasQuery(r *Request) bool { + parsed := r.parsedURL() + if parsed == nil { + return true + } + return parsed.RawQuery != "" +} + +func (g *Gate) hasSystemRestartCapability(r *Request) bool { + if g.local.AttestationSecret == "" || r.Method != "POST" { + return false + } + path := capabilityRequestPath(r) + if path == "" || path != SystemRestartPath { + return false + } + kind, pid := ParseExpectedPID(r.Get(SystemRestartExpectedPIDHeader)) + if kind != ExpectedPIDPresent || pid != int64(g.local.PID) { + return false + } + return VerifySystemRestartCapability( + g.local.AttestationSecret, + r.Get(SystemRestartNonceHeader), + r.Method, + path, + pid, + g.local.Port, + r.Get(SystemRestartCapabilityHeader), + ) +} + +// hasEmptyBodyRequest mirrors the TS content-length === "0" and no +// transfer-encoding preconditions on the reload and gui-pair paths. +func hasEmptyBodyRequest(r *Request) bool { + if r.Get(contentLengthHeader) != "0" { + return false + } + return r.Get(transferEncodingHeader) == "" +} + +func (g *Gate) hasLocalProviderReloadCapability(r *Request, now int64) bool { + if g.local.AttestationSecret == "" || r.Method != "POST" { + return false + } + path := capabilityRequestPath(r) + if path == "" || path != LocalProviderReloadPath { + return false + } + if hasQuery(r) { + return false + } + if !hasEmptyBodyRequest(r) { + return false + } + kind, pid := ParseExpectedPID(r.Get(LocalProviderReloadExpectedPIDHeader)) + if kind != ExpectedPIDPresent || pid != int64(g.local.PID) { + return false + } + expiresAt, ok := parseExpiryHeader(r.Get(LocalProviderReloadExpiresAtHeader)) + if !ok { + return false + } + name := r.Get(LocalProviderReloadNameHeader) + capability := r.Get(LocalProviderReloadCapabilityHeader) + if !VerifyLocalProviderReloadCapability( + g.local.AttestationSecret, + r.Get(LocalProviderReloadNonceHeader), + r.Method, + path, + name, + pid, + g.local.Port, + expiresAt, + capability, + now, + ) { + return false + } + pruneConsumed(g.consumedProviderReload, now) + if capability == "" || consumedHas(g.consumedProviderReload, capability) { + return false + } + if len(g.consumedProviderReload) >= providerReloadReplayLimit { + return false + } + g.consumedProviderReload[capability] = expiresAt + return true +} + +func (g *Gate) hasLocalReadCapability(r *Request, now int64) bool { + if g.local.AttestationSecret == "" || r.Method != "GET" { + return false + } + path := capabilityRequestPath(r) + if path == "" { + return false + } + if hasQuery(r) { + return false + } + kind, pid := ParseExpectedPID(r.Get(LocalManagementExpectedPIDHeader)) + if kind != ExpectedPIDPresent || pid != int64(g.local.PID) { + return false + } + expiresAt, ok := parseExpiryHeader(r.Get(LocalManagementExpiresAtHeader)) + if !ok { + return false + } + capability := r.Get(LocalManagementCapabilityHeader) + if !VerifyLocalManagementReadCapability( + g.local.AttestationSecret, + r.Get(LocalManagementNonceHeader), + r.Method, + path, + pid, + g.local.Port, + expiresAt, + capability, + now, + ) { + return false + } + pruneConsumed(g.consumedLocalRead, now) + if capability == "" || consumedHas(g.consumedLocalRead, capability) { + return false + } + if len(g.consumedLocalRead) >= localReadReplayLimit { + return false + } + g.consumedLocalRead[capability] = expiresAt + return true +} + +func (g *Gate) hasGuiPairCapability(r *Request, now int64) bool { + if g.local.AttestationSecret == "" || r.Method != "POST" { + return false + } + path := capabilityRequestPath(r) + if path == "" || path != GUIPairPath { + return false + } + if hasQuery(r) { + return false + } + if !hasEmptyBodyRequest(r) { + return false + } + kind, pid := ParseExpectedPID(r.Get(GUIPairExpectedPIDHeader)) + if kind != ExpectedPIDPresent || pid != int64(g.local.PID) { + return false + } + expiresAt, ok := parseExpiryHeader(r.Get(GUIPairExpiresAtHeader)) + if !ok { + return false + } + capability := r.Get(GUIPairCapabilityHeader) + if !VerifyGuiPairCapability( + g.local.AttestationSecret, + r.Get(GUIPairNonceHeader), + r.Method, + path, + r.Get(GUIPairBrowserOriginHeader), + pid, + g.local.Port, + expiresAt, + capability, + now, + ) { + return false + } + pruneConsumed(g.consumedGuiPair, now) + if capability == "" { + return false + } + digest := sha256Base64URL(capability) + if consumedHas(g.consumedGuiPair, digest) { + return false + } + if len(g.consumedGuiPair) >= guiPairReplayLimit { + return false + } + g.consumedGuiPair[digest] = expiresAt + return true +} + +func pruneConsumed(store map[string]int64, now int64) { + for consumed, retainedUntil := range store { + if retainedUntil <= now { + delete(store, consumed) + } + } +} + +func consumedHas(store map[string]int64, key string) bool { + _, ok := store[key] + return ok +} + +// sha256Base64URL mirrors the SHA-256 base64url digest the TS side uses to key +// the gui-pair replay store. +func sha256Base64URL(value string) string { + sum := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// unauthorizedBody is the exact 401 body requireManagementAuth returns. +const unauthorizedBody = `{"error":"opencodex admin token required"}` + +// unavailableBody is the exact 503 body for an unavailable management state; +// the reason string is JSON-escaped the way Response.json escapes it. +func unavailableBody(reason string) string { + escaped := strings.ReplaceAll(reason, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + return `{"error":"management API unavailable","reason":"` + escaped + `","hint":"Set OPENCODEX_ADMIN_AUTH_TOKEN to bypass file-backed admin token ACL hardening"}` +} diff --git a/go/internal/managementauth/managementauth_test.go b/go/internal/managementauth/managementauth_test.go new file mode 100644 index 0000000000..a2dc124db2 --- /dev/null +++ b/go/internal/managementauth/managementauth_test.go @@ -0,0 +1,518 @@ +package managementauth + +import ( + "os" + "path/filepath" + "testing" +) + +// Fixed clock base for deterministic expiry/replay tests. Values mirror what +// the TS side calls now: epoch milliseconds. +const testNow = 1_800_000_000_000 + +func testGate(t *testing.T, state State, cfg ConfigView) *Gate { + t.Helper() + gate := NewGate(state, cfg, LocalContext{ + AttestationSecret: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", + PID: 4242, + Port: 10100, + }) + return gate.WithClock(func() int64 { return testNow }) +} + +func req(method, rawURL string, headers map[string]string) *Request { + lower := make(map[string]string, len(headers)) + for name, value := range headers { + lower[lowerHeader(name)] = value + } + return &Request{URL: rawURL, Method: method, Header: lower} +} + +func lowerHeader(name string) string { + out := make([]byte, 0, len(name)) + for i := 0; i < len(name); i++ { + c := name[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + out = append(out, c) + } + return string(out) +} + +func availableState(token string) State { + return State{Available: true, Token: token, Source: "environment", Sessions: map[string]Session{}} +} + +func TestBase64URLAndSecretShape(t *testing.T) { + if !IsBase64URL256("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") { + t.Fatal("43-char base64url secret must be valid") + } + if IsBase64URL256("short") || IsBase64URL256("not+valid/forty3chars") { + t.Fatal("invalid base64url shapes must be rejected") + } + if !IsAttestationSecret("abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") { + t.Fatal("attestation secret shape check must accept a 43-char base64url value") + } + if !EqualSecret("ocx_admin_x", "ocx_admin_x") { + t.Fatal("equal secrets must compare equal") + } + if EqualSecret("ocx_admin_x", "ocx_admin_y") || EqualSecret("a", "ab") { + t.Fatal("unequal secrets must compare unequal") + } +} + +func TestParseExpectedPID(t *testing.T) { + cases := []struct { + value string + kind ExpectedPIDKind + pid int64 + }{ + {"", ExpectedPIDAbsent, 0}, + {"0", ExpectedPIDInvalid, 0}, + {"007", ExpectedPIDInvalid, 0}, + {"-1", ExpectedPIDInvalid, 0}, + {"4242", ExpectedPIDPresent, 4242}, + {"1", ExpectedPIDPresent, 1}, + {"1.5", ExpectedPIDInvalid, 0}, + {"abc", ExpectedPIDInvalid, 0}, + } + for _, c := range cases { + kind, pid := ParseExpectedPID(c.value) + if kind != c.kind || pid != c.pid { + t.Errorf("ParseExpectedPID(%q) = (%s, %d), want (%s, %d)", c.value, kind, pid, c.kind, c.pid) + } + } +} + +func TestCapabilityRoundTrips(t *testing.T) { + secret := "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" + nonce := "GFEDCBA9876543210zyxwvutsrqponmlkjihgfedcba" + pid := int64(4242) + port := 10100 + now := int64(testNow) + expiresAt := now + 5_000 + + if len(nonce) != 43 { + t.Fatalf("nonce fixture must be 43 chars, got %d", len(nonce)) + } + + // System restart: no expiry window; any time verifies. + cap := CreateSystemRestartCapability(secret, nonce, SystemRestartMethod, SystemRestartPath, pid, port) + if cap == "" { + t.Fatal("restart capability must mint") + } + if !VerifySystemRestartCapability(secret, nonce, SystemRestartMethod, SystemRestartPath, pid, port, cap) { + t.Fatal("restart capability must verify") + } + if VerifySystemRestartCapability(secret, nonce, "PUT", SystemRestartPath, pid, port, cap) { + t.Fatal("restart capability for a different method must not verify") + } + if VerifySystemRestartCapability(secret, nonce, SystemRestartMethod, SystemRestartPath, pid, port, cap+"A") { + t.Fatal("tampered restart capability must not verify") + } + + // Local read: TTL window enforced. + readCap := CreateLocalManagementReadCapability(secret, nonce, "GET", LocalManagementReadPathSystemMemory, pid, port, expiresAt) + if readCap == "" { + t.Fatal("read capability must mint") + } + if !VerifyLocalManagementReadCapability(secret, nonce, "GET", LocalManagementReadPathSystemMemory, pid, port, expiresAt, readCap, now) { + t.Fatal("read capability must verify within its TTL") + } + if VerifyLocalManagementReadCapability(secret, nonce, "GET", LocalManagementReadPathSystemMemory, pid, port, expiresAt, readCap, expiresAt+1) { + t.Fatal("read capability must not verify after expiry") + } + if VerifyLocalManagementReadCapability(secret, nonce, "GET", LocalManagementReadPathSystemMemory, pid, port, expiresAt, readCap, now+LocalManagementCapabilityTTLMs+1) { + t.Fatal("read capability must not verify beyond the TTL ceiling") + } + if VerifyLocalManagementReadCapability(secret, nonce, "GET", "/api/system/memory/", pid, port, expiresAt, readCap, now) { + t.Fatal("read capability for a non-allowlisted path must not verify") + } + if VerifyLocalManagementReadCapability(secret, nonce, "GET", "/api/system/memory", pid, port, expiresAt, "", now) { + t.Fatal("missing capability must not verify") + } + + // Provider reload: name binding. + reloadCap := CreateLocalProviderReloadCapability(secret, nonce, LocalProviderReloadMethod, LocalProviderReloadPath, "openai", pid, port, expiresAt) + if reloadCap == "" { + t.Fatal("reload capability must mint") + } + if !VerifyLocalProviderReloadCapability(secret, nonce, LocalProviderReloadMethod, LocalProviderReloadPath, "openai", pid, port, expiresAt, reloadCap, now) { + t.Fatal("reload capability must verify") + } + if VerifyLocalProviderReloadCapability(secret, nonce, LocalProviderReloadMethod, LocalProviderReloadPath, "other", pid, port, expiresAt, reloadCap, now) { + t.Fatal("reload capability for another provider name must not verify") + } + if CreateLocalProviderReloadCapability(secret, nonce, LocalProviderReloadMethod, LocalProviderReloadPath, "not valid!", pid, port, expiresAt) != "" { + t.Fatal("reload capability with an invalid provider name must not mint") + } + + // GUI pair: browser-origin canonicalisation is part of the payload. + guiCap := CreateGuiPairCapability(secret, nonce, GUIPairMethod, GUIPairPath, "https://ocx.example", pid, port, expiresAt) + if guiCap == "" { + t.Fatal("gui-pair capability must mint") + } + if !VerifyGuiPairCapability(secret, nonce, GUIPairMethod, GUIPairPath, "https://ocx.example", pid, port, expiresAt, guiCap, now) { + t.Fatal("gui-pair capability must verify") + } + if VerifyGuiPairCapability(secret, nonce, GUIPairMethod, GUIPairPath, "https://ocx.example:443", pid, port, expiresAt, guiCap, now) { + t.Fatal("gui-pair capability payload binds the canonical origin; default-port spelling must not verify") + } + if got := CreateGuiPairCapability(secret, nonce, GUIPairMethod, GUIPairPath, "https://ocx.example:443", pid, port, expiresAt); got != "" { + t.Fatalf("minting with a non-canonical origin must be refused, got %q", got) + } + + // Attestation proof. + proof := CreateLocalAttestationProof(secret, "QAZWSXEDCRFVTGBYHNUJMIKOLPqazwsxedcrfvtgbay", pid, port) + if proof == "" { + t.Fatal("attestation proof must mint") + } + if !VerifyLocalAttestationProof(secret, "QAZWSXEDCRFVTGBYHNUJMIKOLPqazwsxedcrfvtgbay", pid, port, proof) { + t.Fatal("attestation proof must verify") + } +} + +func TestCanonicalGuiBrowserOrigin(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"https://ocx.example", "https://ocx.example"}, + {"https://ocx.example:443", "https://ocx.example"}, + {"http://ocx.example:8080", "http://ocx.example:8080"}, + {"http://localhost:10100", "http://localhost:10100"}, + {"https://ocx.example/", "https://ocx.example"}, + {"https://ocx.example/path", ""}, + {"https://user:pass@ocx.example", ""}, + {"https://ocx.example?q=1", ""}, + {"https://ocx.example#f", ""}, + {" not-trimmed ", ""}, + {"chrome-extension://abc", "chrome-extension://abc"}, + {"file:///etc/hosts", ""}, + {"", ""}, + } + for _, c := range cases { + if got := CanonicalGuiBrowserOrigin(c.in); got != c.want { + t.Errorf("CanonicalGuiBrowserOrigin(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestManagementRequestOrigin(t *testing.T) { + loopback := ConfigView{Hostname: "127.0.0.1"} + remote := ConfigView{Hostname: "0.0.0.0"} + hub := ConfigView{Hostname: "0.0.0.0", RuntimeRole: "hub", HubManagementPublicOrigin: "https://ocx.example"} + + cases := []struct { + name string + r *Request + cfg ConfigView + want string + }{ + { + name: "loopback observed origin with port", + r: req("GET", "http://127.0.0.1:10100/api/config", map[string]string{"Host": "127.0.0.1:10100"}), + cfg: loopback, + want: "http://127.0.0.1:10100", + }, + { + name: "loopback default port dropped", + r: req("GET", "http://localhost/api/config", map[string]string{"Host": "localhost"}), + cfg: loopback, + want: "http://localhost", + }, + { + name: "localhost trailing dot is loopback", + r: req("GET", "http://localhost.:10100/api/config", map[string]string{"Host": "localhost.:10100"}), + cfg: loopback, + want: "http://localhost.:10100", + }, + { + name: "non-loopback without api auth has no origin", + r: req("GET", "http://mynode.lan:10100/api/config", map[string]string{"Host": "mynode.lan:10100"}), + cfg: ConfigView{Hostname: "localhost"}, + want: "", + }, + { + name: "non-loopback observed origin when api auth required", + r: req("GET", "http://mynode.lan:10100/api/config", map[string]string{"Host": "mynode.lan:10100"}), + cfg: remote, + want: "http://mynode.lan:10100", + }, + { + name: "hub uses its configured public origin", + r: req("GET", "http://10.0.0.5:10100/api/config", map[string]string{"Host": "10.0.0.5:10100"}), + cfg: hub, + want: "https://ocx.example", + }, + { + name: "missing host header has no origin", + r: req("GET", "http://127.0.0.1:10100/api/config", nil), + cfg: loopback, + want: "", + }, + } + for _, c := range cases { + if got := ManagementRequestOrigin(c.r, c.cfg); got != c.want { + t.Errorf("%s: origin = %q, want %q", c.name, got, c.want) + } + } +} + +func TestAdminTokenAdmission(t *testing.T) { + token := "ocx_admin_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" + gate := testGate(t, availableState(token), ConfigView{Hostname: "127.0.0.1"}) + + // Correct token via the API-key header. + decision := gate.Admit(req("GET", "http://127.0.0.1:10100/api/config", map[string]string{ + "x-opencodex-api-key": token, + "host": "127.0.0.1:10100", + })) + if decision.Principal != PrincipalAdminToken { + t.Fatalf("correct token must admit as admin-token, got %q (rejection %+v)", decision.Principal, decision.Rejection) + } + + // Wrong token, state available -> 401 with the exact TS body. + decision = gate.Admit(req("GET", "http://127.0.0.1:10100/api/config", map[string]string{ + "authorization": "Bearer wrong-token", + "host": "127.0.0.1:10100", + })) + if decision.Rejection == nil || decision.Rejection.Status != 401 { + t.Fatalf("wrong token must be rejected with 401, got %+v", decision.Rejection) + } + if decision.Rejection.Body != `{"error":"opencodex admin token required"}` { + t.Fatalf("401 body = %q", decision.Rejection.Body) + } + + // Bearer stripping is case-insensitive and whitespace-trimmed. + decision = gate.Admit(req("GET", "http://127.0.0.1:10100/api/config", map[string]string{ + "authorization": "bearer " + token + " ", + "host": "127.0.0.1:10100", + })) + if decision.Principal != PrincipalAdminToken { + t.Fatalf("bearer-prefixed token must admit, got %q", decision.Principal) + } + + // Unavailable state -> 503 with reason and hint. + unavailable := State{Available: false, Reason: "management token initialization failed"} + decision = testGate(t, unavailable, ConfigView{Hostname: "127.0.0.1"}).Admit( + req("GET", "http://127.0.0.1:10100/api/config", nil), + ) + if decision.Rejection == nil || decision.Rejection.Status != 503 { + t.Fatalf("unavailable state must reject with 503, got %+v", decision.Rejection) + } + want := `{"error":"management API unavailable","reason":"management token initialization failed","hint":"Set OPENCODEX_ADMIN_AUTH_TOKEN to bypass file-backed admin token ACL hardening"}` + if decision.Rejection.Body != want { + t.Fatalf("503 body:\n got %q\nwant %q", decision.Rejection.Body, want) + } +} + +func TestCapabilityAdmissionOrderAndReplay(t *testing.T) { + state := availableState("ocx_admin_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") + secret := "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" + local := LocalContext{AttestationSecret: secret, PID: 4242, Port: 10100} + gate := NewGate(state, ConfigView{Hostname: "127.0.0.1"}, local).WithClock(func() int64 { return testNow }) + expiresAt := int64(testNow + 5_000) + nonce := "GFEDCBA9876543210zyxwvutsrqponmlkjihgfedcba" + + // A valid local-read capability admits with the capability principal even + // though the admin token is wrong on the request. + cap := CreateLocalManagementReadCapability(secret, nonce, "GET", LocalManagementReadPathCodexAccounts, 4242, 10100, expiresAt) + r := req("GET", "http://127.0.0.1:10100/api/codex-auth/accounts", map[string]string{ + "host": "127.0.0.1:10100", + LocalManagementExpectedPIDHeader: "4242", + LocalManagementNonceHeader: nonce, + LocalManagementExpiresAtHeader: "1800000005000", + LocalManagementCapabilityHeader: cap, + }) + decision := gate.Admit(r) + if decision.Principal != PrincipalLocalReadCapability { + t.Fatalf("capability must admit ahead of the token check, got %q", decision.Principal) + } + + // A second, distinct request with the same capability is a replay and must + // be rejected exactly like the TS consumed-capability store rejects it. + replay := req("GET", "http://127.0.0.1:10100/api/codex-auth/accounts", map[string]string{ + "host": "127.0.0.1:10100", + LocalManagementExpectedPIDHeader: "4242", + LocalManagementNonceHeader: nonce, + LocalManagementExpiresAtHeader: "1800000005000", + LocalManagementCapabilityHeader: cap, + }) + decision = gate.Admit(replay) + if decision.Principal != "" || decision.Rejection == nil || decision.Rejection.Status != 401 { + t.Fatalf("replayed capability must be rejected with 401, got %+v", decision) + } + + // Wrong expected pid never reaches verification. + wrongPid := req("GET", "http://127.0.0.1:10100/api/codex-auth/accounts", map[string]string{ + LocalManagementExpectedPIDHeader: "1", + LocalManagementCapabilityHeader: cap, + }) + if decision := gate.Admit(wrongPid); decision.Principal != "" { + t.Fatalf("wrong expected pid must reject, got %q", decision.Principal) + } + + // A query string disqualifies the narrow local-read grant. + withQuery := req("GET", "http://127.0.0.1:10100/api/codex-auth/accounts?x=1", map[string]string{ + LocalManagementExpectedPIDHeader: "4242", + }) + if decision := gate.Admit(withQuery); decision.Principal != "" { + t.Fatalf("query-bearing local read must reject, got %q", decision.Principal) + } + + // Non-POST never matches the gui-pair path. + pairCap := CreateGuiPairCapability(secret, nonce, GUIPairMethod, GUIPairPath, "http://localhost:5173", 4242, 10100, expiresAt) + pairReq := req("GET", "http://127.0.0.1:10100/api/gui/pairing-grants", map[string]string{ + GUIPairExpectedPIDHeader: "4242", + GUIPairCapabilityHeader: pairCap, + GUIPairBrowserOriginHeader: "http://localhost:5173", + GUIPairExpiresAtHeader: "1800000005000", + GUIPairNonceHeader: nonce, + "content-length": "0", + }) + if decision := gate.Admit(pairReq); decision.Principal != "" { + t.Fatalf("GET on the gui-pair POST path must reject, got %q", decision.Principal) + } +} + +func TestSessionAuthorization(t *testing.T) { + state := availableState("ocx_admin_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG") + cfg := ConfigView{Hostname: "127.0.0.1"} + gate := testGate(t, state, cfg) + + // Mint a session the way the TS side does (loopback issuance records the + // observed origin as both server and browser origin). + mint := req("GET", "http://127.0.0.1:10100/api/session/bootstrap", map[string]string{"host": "127.0.0.1:10100"}) + serverOrigin := ManagementRequestOrigin(mint, cfg) + if serverOrigin != "http://127.0.0.1:10100" { + t.Fatalf("fixture origin = %q", serverOrigin) + } + token := "ocx_session_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + session := Session{ + ServerOrigin: serverOrigin, + BrowserOrigin: serverOrigin, + CSRF: "csrf-token-value-1234567890123456789012345", + ExpiresAt: testNow + loopbackGuiSessionTTLMs, + Issuance: "loopback", + } + sessions := map[string]Session{token: session} + gate.state.Sessions = sessions + + // A safe GET with the session token and matching origin admits. + good := req("GET", "http://127.0.0.1:10100/api/config", map[string]string{ + "host": "127.0.0.1:10100", + "x-opencodex-api-key": token, + "x-opencodex-gui-origin": serverOrigin, + }) + decision := gate.Admit(good) + if decision.Principal != PrincipalGuiSession { + t.Fatalf("valid session GET must admit as gui-session, got %q", decision.Principal) + } + + // An unsafe POST without the CSRF token rejects with the exact reason. + mutation := req("POST", "http://127.0.0.1:10100/api/config", map[string]string{ + "host": "127.0.0.1:10100", + "x-opencodex-api-key": token, + "origin": serverOrigin, + "x-opencodex-gui-origin": serverOrigin, + }) + admission := AuthorizeSession(mutation, cfg, sessions, testNow) + if admission.OK || admission.Reason != SessionCSRF { + t.Fatalf("unsafe session mutation without CSRF must reject with csrf reason, got %+v", admission) + } + if decision := gate.Admit(mutation); decision.Principal != "" { + t.Fatalf("session mutation without CSRF must not admit, got %q", decision.Principal) + } + + // With the correct CSRF header it admits. + withCSRF := req("POST", "http://127.0.0.1:10100/api/config", map[string]string{ + "host": "127.0.0.1:10100", + "x-opencodex-api-key": token, + "origin": serverOrigin, + "x-opencodex-gui-origin": serverOrigin, + "x-opencodex-csrf-token": session.CSRF, + }) + if decision := gate.Admit(withCSRF); decision.Principal != PrincipalGuiSession { + t.Fatalf("session mutation with CSRF must admit, got %q", decision.Principal) + } + + // A mismatched browser origin rejects. + mismatched := req("GET", "http://127.0.0.1:10100/api/config", map[string]string{ + "host": "127.0.0.1:10100", + "x-opencodex-api-key": token, + "x-opencodex-gui-origin": "http://evil.example", + }) + if decision := gate.Admit(mismatched); decision.Principal != "" { + t.Fatalf("mismatched browser origin must reject, got %q", decision.Principal) + } + + // An expired session deletes and rejects. + expiredToken := "ocx_session_zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" + sessions[expiredToken] = Session{ + ServerOrigin: serverOrigin, + BrowserOrigin: serverOrigin, + CSRF: "x", + ExpiresAt: testNow - 1, + Issuance: "loopback", + } + expired := req("GET", "http://127.0.0.1:10100/api/config", map[string]string{ + "host": "127.0.0.1:10100", + "x-opencodex-api-key": expiredToken, + }) + admission = AuthorizeSession(expired, cfg, sessions, testNow) + if admission.OK || admission.Reason != SessionExpired { + t.Fatalf("expired session must reject with expired reason, got %+v", admission) + } + if _, stillPresent := sessions[expiredToken]; stillPresent { + t.Fatal("expired session must be deleted from the table") + } +} + +func TestAdminTokenFileLoad(t *testing.T) { + dir := t.TempDir() + valid := "ocx_admin_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" + + t.Run("missing file yields no token", func(t *testing.T) { + if got := LoadAdminToken(dir); got != "" { + t.Fatalf("missing token file must load no token, got %q", got) + } + }) + + t.Run("valid file loads", func(t *testing.T) { + path := AdminTokenFilePath(dir) + if err := os.WriteFile(path, []byte(valid+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := LoadAdminToken(dir); got != valid { + t.Fatalf("valid token file must load, got %q", got) + } + }) + + t.Run("malformed content yields no token", func(t *testing.T) { + sub := t.TempDir() + path := filepath.Join(sub, "admin-api-token") + if err := os.WriteFile(path, []byte("not-a-token\n"), 0o600); err != nil { + t.Fatal(err) + } + if got := LoadAdminToken(sub); got != "" { + t.Fatalf("malformed token file must load no token, got %q", got) + } + }) + + t.Run("oversized file yields no token", func(t *testing.T) { + sub := t.TempDir() + path := filepath.Join(sub, "admin-api-token") + big := make([]byte, 600) + for i := range big { + big[i] = 'a' + } + if err := os.WriteFile(path, big, 0o600); err != nil { + t.Fatal(err) + } + if got := LoadAdminToken(sub); got != "" { + t.Fatalf("oversized token file must load no token, got %q", got) + } + }) +} diff --git a/go/internal/managementauth/session.go b/go/internal/managementauth/session.go new file mode 100644 index 0000000000..ce5aa6b28f --- /dev/null +++ b/go/internal/managementauth/session.go @@ -0,0 +1,228 @@ +package managementauth + +// Origin machinery and admin-token loading (ADR-0008, ticket #18). The origin +// functions mirror src/server/auth-cors.ts (parseHttpHost, isLoopbackHostname, +// isApiAuthRequired, managementRequestOrigin) because dashboard-session +// authorization compares the request's derived server origin against the +// origin recorded on the session at mint time; a mismatch must reject exactly +// when TypeScript rejects. + +import ( + "net/url" + "os" + "path/filepath" + "regexp" + "strings" +) + +// adminTokenPattern mirrors the token-file shape check in +// src/server/management-auth.ts (readExistingToken) and src/lib/admin-secrets.ts. +var adminTokenPattern = regexp.MustCompile(`^ocx_admin_[A-Za-z0-9_-]{43}$`) + +// ADMIN_TOKEN_FILE mirrors src/lib/admin-secrets.ts. +const ADMIN_TOKEN_FILE = "admin-api-token" + +// AdminTokenFilePath mirrors adminApiTokenFilePath. +func AdminTokenFilePath(configDir string) string { + return filepath.Join(configDir, ADMIN_TOKEN_FILE) +} + +// LoadAdminToken mirrors loadAdminTokenFromFile in src/lib/admin-secrets.ts: +// a regular, non-symlink file of at most 512 bytes whose trimmed content has +// the ocx_admin_ shape. It never creates or hardens the file: token-file +// creation and ACL hardening are the serving process's job at the flip, and a +// read-only sidecar must not mutate the parent's secret file pre-flip. +func LoadAdminToken(configDir string) string { + path := AdminTokenFilePath(configDir) + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > 512 { + return "" + } + raw, err := os.ReadFile(path) + if err != nil { + return "" + } + token := strings.TrimSpace(string(raw)) + if !adminTokenPattern.MatchString(token) { + return "" + } + return token +} + +// EnvAdminToken mirrors the OPENCODEX_ADMIN_AUTH_TOKEN trim in +// src/server/management-auth.ts (environmentToken). No shape check applies to +// an environment token; equality is what matters. +func EnvAdminToken(environ func(string) string) string { + return strings.TrimSpace(environ("OPENCODEX_ADMIN_AUTH_TOKEN")) +} + +// EqualSecret mirrors equalSecret: timing-safe byte equality with a length +// pre-check, over UTF-8 encodings. +func EqualSecret(actual, expected string) bool { + left := []byte(actual) + right := []byte(expected) + if len(left) != len(right) { + return false + } + return equalCapabilityBytes(string(left), string(right)) +} + +// Request is the admission-relevant slice of one HTTP request: the full URL +// (protocol/host/path/search as TypeScript sees them), the method, and the +// headers with names lowercased. An absent header and an empty value are the +// same thing here; see capability.go for why that never changes a decision. +type Request struct { + URL string + Method string + Header map[string]string +} + +// Get mirrors Headers.get with case-insensitive names. +func (r *Request) Get(name string) string { + if r == nil || r.Header == nil { + return "" + } + return r.Header[strings.ToLower(name)] +} + +// parsedURL lazily parses r.URL; nil when unparseable. +func (r *Request) parsedURL() *url.URL { + parsed, err := url.Parse(r.URL) + if err != nil { + return nil + } + return parsed +} + +// MethodName returns the method upper-cased the way Go HTTP normalises it +// (the TS side receives whatever the client sent; the capability contracts +// require exact uppercase POST/GET so the comparison is by exact string). +func (r *Request) MethodName() string { + return r.Method +} + +// parseHTTPHost mirrors parseHttpHost in src/server/auth-cors.ts: parse the +// Host header as http:// and return the lowercased WHATWG hostname +// (IPv6 bracketed) and the URL port ("" when default/absent). Nil means the +// header was absent or unparseable. +func parseHTTPHost(value string) *struct { + Hostname string + Port string +} { + if value == "" { + return nil + } + parsed, err := url.Parse("http://" + value) + if err != nil { + return nil + } + if parsed.Host == "" { + return nil + } + return &struct { + Hostname string + Port string + }{Hostname: whatwgHostname(parsed), Port: parsed.Port()} +} + +// whatwgHostname returns the hostname the way URL.hostname serialises it: +// lowercased, IPv6 bracketed. Go's Hostname() strips brackets and keeps case, +// so this rebuilds the WHATWG form. +func whatwgHostname(u *url.URL) string { + host := u.Hostname() + lower := strings.ToLower(host) + if strings.Contains(lower, ":") { + return "[" + lower + "]" + } + return lower +} + +// isLoopbackHostname mirrors isLoopbackHostname in src/server/auth-cors.ts: +// the normalized hostname (trimmed, lowercased, one trailing dot stripped) is +// empty, "localhost", "127.0.0.1", "::1", or "[::1]". An empty input stays +// empty (loopback); the "127.0.0.1" default in the TS side applies only to +// undefined, which cannot occur here. +func isLoopbackHostname(hostname string) bool { + normalized := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(hostname)), ".") + switch normalized { + case "", "localhost", "127.0.0.1", "::1", "[::1]": + return true + } + return false +} + +// IsApiAuthRequired mirrors isApiAuthRequired: false exactly when the +// configured hostname is a loopback hostname. +func IsApiAuthRequired(cfg ConfigView) bool { + return !isLoopbackHostname(cfg.Hostname) +} + +// ManagementRequestOrigin mirrors managementRequestOrigin in +// src/server/auth-cors.ts. It derives the origin a request was served from: +// for a loopback Host the observed protocol+host; for a non-loopback Host only +// when auth is required, preferring the hub's configured public origin when +// this process runs as a hub and one is configured. Empty means no origin. +func ManagementRequestOrigin(r *Request, cfg ConfigView) string { + host := r.Get("host") + parsedHost := parseHTTPHost(host) + if parsedHost == nil { + return "" + } + if isLoopbackHostname(parsedHost.Hostname) { + parsed := r.parsedURL() + if parsed == nil { + return "" + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "" + } + origin, err := url.Parse(parsed.Scheme + "://" + host) + if err != nil { + return "" + } + return whatwgOrigin(origin) + } + if !IsApiAuthRequired(cfg) { + return "" + } + if cfg.RuntimeRole == "hub" && cfg.HubManagementPublicOrigin != "" { + configured, err := url.Parse(cfg.HubManagementPublicOrigin) + if err == nil && configured.Host != "" { + valid := (configured.Scheme == "http" || configured.Scheme == "https") && + configured.User == nil && (configured.Path == "" || configured.Path == "/") && + configured.RawQuery == "" && configured.Fragment == "" + if valid { + return whatwgOrigin(configured) + } + } + } + parsed := r.parsedURL() + if parsed == nil { + return "" + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "" + } + origin, err := url.Parse(parsed.Scheme + "://" + host) + if err != nil { + return "" + } + return whatwgOrigin(origin) +} + +// RequestManagementCredential mirrors requestManagementCredential: the +// x-opencodex-api-key header trimmed, else the Authorization header with a +// case-insensitive "Bearer " prefix stripped and the result trimmed. +func RequestManagementCredential(r *Request) string { + if value := strings.TrimSpace(r.Get("x-opencodex-api-key")); value != "" { + return value + } + authorization := r.Get("authorization") + if authorization == "" { + return "" + } + stripped := bearerPrefixPattern.ReplaceAllString(authorization, "") + return strings.TrimSpace(stripped) +} + +var bearerPrefixPattern = regexp.MustCompile(`(?i)^Bearer\s+`) diff --git a/go/internal/managementauth/write_relay.go b/go/internal/managementauth/write_relay.go new file mode 100644 index 0000000000..82b7086341 --- /dev/null +++ b/go/internal/managementauth/write_relay.go @@ -0,0 +1,144 @@ +package managementauth + +// Write-relay proofs bind a TypeScript front-door admission to one specific +// request the Go sidecar is allowed to send back to its private parent bridge. +// They are deliberately separate from public management credentials: the +// sidecar receives neither an admin token nor a dashboard session secret. + +import ( + "crypto/sha256" + "encoding/hex" + "regexp" + "sync" + "time" +) + +const ( + // WriteRelayNonceHeader carries a fresh base64url nonce from the admitting front door. + WriteRelayNonceHeader = "X-Ocx-Go-Sidecar-Relay-Nonce" + // WriteRelayPrincipalHeader carries the principal the front door admitted. + WriteRelayPrincipalHeader = "X-Ocx-Go-Sidecar-Relay-Principal" + // WriteRelayExpiresAtHeader is a decimal epoch-millisecond deadline. + WriteRelayExpiresAtHeader = "X-Ocx-Go-Sidecar-Relay-Expires-At" + // WriteRelayProofHeader carries the HMAC-SHA256 relay proof. + WriteRelayProofHeader = "X-Ocx-Go-Sidecar-Relay-Proof" + // WriteRelayReplayLimit bounds retained consumed nonces for one sidecar process. + WriteRelayReplayLimit = 256 +) + +const writeRelayTTLMillis int64 = int64(30 * time.Second / time.Millisecond) + +var writeRelayNoncePattern = regexp.MustCompile("^[A-Za-z0-9_-]{43}$") + +// WriteRelayProof is the header-derived assertion to verify for one body. +// Method and Path are supplied from the actual sidecar request, rather than +// trusted from a header, so a proof cannot be replayed onto another route. +type WriteRelayProof struct { + Nonce string + Principal Principal + Method string + Path string + ExpiresAt int64 + Proof string +} + +// WriteRelayVerifier owns the bounded one-use nonce table for one sidecar +// process. It is safe for concurrent requests. +type WriteRelayVerifier struct { + mu sync.Mutex + secret string + nowFn func() int64 + consumed map[string]int64 +} + +// NewWriteRelayVerifier creates a verifier over the shared private bridge +// secret. An empty secret is deliberately unusable and fails every proof. +func NewWriteRelayVerifier(secret string) *WriteRelayVerifier { + return &WriteRelayVerifier{ + secret: secret, + // `time.Now().UnixMilli` would bind UnixMilli to the instant at verifier + // construction. The relay's maximum-TTL check must use the request time. + nowFn: func() int64 { return time.Now().UnixMilli() }, + consumed: map[string]int64{}, + } +} + +// WithClock replaces the wall-clock source for deterministic tests. +func (v *WriteRelayVerifier) WithClock(now func() int64) *WriteRelayVerifier { + v.nowFn = now + return v +} + +// ParseWriteRelayExpiry accepts the canonical decimal header form used by the +// existing local capability contracts. It rejects zero, signs, whitespace and +// overflow rather than normalising attacker-controlled input. +func ParseWriteRelayExpiry(value string) (int64, bool) { return parseExpiryHeader(value) } + +// CreateWriteRelayProof signs a one-use proof. Empty means the supplied +// binding is invalid. The HMAC payload is versioned and newline-delimited: +// nonce, principal, method, path, SHA-256(body) in lowercase hex, expiry. +func CreateWriteRelayProof(secret string, proof WriteRelayProof, body []byte) string { + if secret == "" { + return "" + } + payload, ok := writeRelayPayload(proof, body) + if !ok { + return "" + } + return hmacBase64URL(secret, payload) +} + +// VerifyAndConsume checks the complete proof then records its nonce before a +// caller can dispatch the parent mutation. Reusing a nonce, a changed body, +// principal, method, path or expiry all fail. Consumption happens while the +// mutex is held so concurrent requests cannot both spend one proof. +func (v *WriteRelayVerifier) VerifyAndConsume(proof WriteRelayProof, body []byte) bool { + if v == nil || v.secret == "" || !base64URL256.MatchString(proof.Proof) { + return false + } + payload, ok := writeRelayPayload(proof, body) + if !ok { + return false + } + v.mu.Lock() + defer v.mu.Unlock() + now := v.nowFn() + if !expiryWithin(now, proof.ExpiresAt, writeRelayTTLMillis) { + return false + } + expected := hmacBase64URL(v.secret, payload) + if expected == "" || !equalCapabilityBytes(expected, proof.Proof) { + return false + } + pruneConsumed(v.consumed, now) + if _, replayed := v.consumed[proof.Nonce]; replayed || len(v.consumed) >= WriteRelayReplayLimit { + return false + } + v.consumed[proof.Nonce] = proof.ExpiresAt + return true +} + +func writeRelayPayload(proof WriteRelayProof, body []byte) (string, bool) { + if !writeRelayNoncePattern.MatchString(proof.Nonce) || !isWriteRelayPrincipal(proof.Principal) { + return "", false + } + if !writeRelayMethodPattern.MatchString(proof.Method) || !writeRelayPathPattern.MatchString(proof.Path) || proof.ExpiresAt <= 0 { + return "", false + } + digest := sha256.Sum256(body) + return "opencodex-go-write-relay-v1\n" + proof.Nonce + "\n" + string(proof.Principal) + "\n" + proof.Method + "\n" + proof.Path + "\n" + hex.EncodeToString(digest[:]) + "\n" + itoa(proof.ExpiresAt), true +} + +var ( + writeRelayMethodPattern = regexp.MustCompile("^[A-Z]+$") + writeRelayPathPattern = regexp.MustCompile("^/[^?#\\r\\n]*$") +) + +func isWriteRelayPrincipal(principal Principal) bool { + switch principal { + case PrincipalAdminToken, PrincipalGuiSession, PrincipalGuiPairCapability, PrincipalLocalReadCapability, PrincipalLocalProviderReloadCapability, PrincipalSystemRestartCapability: + return true + default: + return false + } +} diff --git a/go/internal/managementauth/write_relay_test.go b/go/internal/managementauth/write_relay_test.go new file mode 100644 index 0000000000..be4d0fa04f --- /dev/null +++ b/go/internal/managementauth/write_relay_test.go @@ -0,0 +1,92 @@ +package managementauth + +import ( + "fmt" + "testing" +) + +const writeRelaySecret = "sidecar-private-bridge-secret" + +func relayProof(nonce string, expiresAt int64) WriteRelayProof { + return WriteRelayProof{Nonce: nonce, Principal: PrincipalAdminToken, Method: "PUT", Path: "/api/settings", ExpiresAt: expiresAt} +} + +func TestWriteRelayProofBindsEveryMutationInputAndConsumesNonce(t *testing.T) { + const now = int64(1800000000000) + body := []byte("{\"streamMode\":\"eager-relay\"}") + proof := relayProof(fmt.Sprintf("%043d", 1), now+1000) + proof.Proof = CreateWriteRelayProof(writeRelaySecret, proof, body) + if proof.Proof == "" { + t.Fatal("CreateWriteRelayProof returned empty proof") + } + verifier := NewWriteRelayVerifier(writeRelaySecret).WithClock(func() int64 { return now }) + if !verifier.VerifyAndConsume(proof, body) { + t.Fatal("valid relay proof was rejected") + } + if verifier.VerifyAndConsume(proof, body) { + t.Fatal("replayed nonce was accepted") + } + for _, mutation := range []struct { + name string + edit func(*WriteRelayProof, *[]byte) + }{ + {"body", func(_ *WriteRelayProof, b *[]byte) { *b = []byte("{\"streamMode\":\"auto\"}") }}, + {"principal", func(p *WriteRelayProof, _ *[]byte) { p.Principal = PrincipalGuiSession }}, + {"method", func(p *WriteRelayProof, _ *[]byte) { p.Method = "POST" }}, + {"path", func(p *WriteRelayProof, _ *[]byte) { p.Path = "/api/shadow-call-settings" }}, + {"expiry", func(p *WriteRelayProof, _ *[]byte) { p.ExpiresAt++ }}, + } { + t.Run(mutation.name, func(t *testing.T) { + candidate := proof + candidate.Nonce = fmt.Sprintf("%043d", 2) + candidate.Proof = CreateWriteRelayProof(writeRelaySecret, candidate, body) + candidateBody := append([]byte(nil), body...) + mutation.edit(&candidate, &candidateBody) + if NewWriteRelayVerifier(writeRelaySecret).WithClock(func() int64 { return now }).VerifyAndConsume(candidate, candidateBody) { + t.Fatalf("proof with changed %s was accepted", mutation.name) + } + }) + } +} + +func TestWriteRelayProofMatchesTypeScriptHMACFixture(t *testing.T) { + body := []byte(`{"enabled":false}`) + proof := WriteRelayProof{ + Nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", Principal: PrincipalAdminToken, + Method: "PUT", Path: "/api/shadow-call-settings", ExpiresAt: 1800000001000, + } + if got := CreateWriteRelayProof("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", proof, body); got != "_NzTNrMpPfCxQFlOn8gAkgKEaOnP9jP8nGBiLu3x4UI" { + t.Fatalf("Go proof = %q; differs from TypeScript fixture", got) + } +} + +func TestWriteRelayProofRejectsExpiredInvalidAndSaturatedNonces(t *testing.T) { + const now = int64(1800000000000) + body := []byte("{}") + verifier := NewWriteRelayVerifier(writeRelaySecret).WithClock(func() int64 { return now }) + for _, expiresAt := range []int64{now, now - 1, now + writeRelayTTLMillis + 1} { + proof := relayProof(fmt.Sprintf("%043d", 1), expiresAt) + proof.Proof = CreateWriteRelayProof(writeRelaySecret, proof, body) + if verifier.VerifyAndConsume(proof, body) { + t.Fatalf("expiry %d was accepted", expiresAt) + } + } + if _, ok := ParseWriteRelayExpiry("001"); ok { + t.Fatal("non-canonical expiry was accepted") + } + if CreateWriteRelayProof(writeRelaySecret, WriteRelayProof{Nonce: "bad", Principal: PrincipalAdminToken, Method: "PUT", Path: "/api/settings", ExpiresAt: now + 1}, body) != "" { + t.Fatal("invalid nonce produced a proof") + } + for i := 0; i < WriteRelayReplayLimit; i++ { + proof := relayProof(fmt.Sprintf("%043d", i+1), now+10000) + proof.Proof = CreateWriteRelayProof(writeRelaySecret, proof, body) + if !verifier.VerifyAndConsume(proof, body) { + t.Fatalf("proof %d was rejected before the replay limit", i) + } + } + extra := relayProof(fmt.Sprintf("%043d", WriteRelayReplayLimit+1), now+10000) + extra.Proof = CreateWriteRelayProof(writeRelaySecret, extra, body) + if verifier.VerifyAndConsume(extra, body) { + t.Fatal("proof was accepted after replay table reached its limit") + } +} diff --git a/go/internal/ocxcli/access_command.go b/go/internal/ocxcli/access_command.go new file mode 100644 index 0000000000..d0129a8ec5 --- /dev/null +++ b/go/internal/ocxcli/access_command.go @@ -0,0 +1,444 @@ +// ocx access + ocx api-key — the admission-key and endpoint family. This file +// ports the TypeScript owner (src/cli/access.ts, shared by both spellings: +// `api-key` dispatches as `access key`) against the same management endpoints +// (/api/keys, /api/keys/rotate[/commit], /v1/models, /v1/chat/completions, +// /v1/responses, /v1/messages) with byte-identical parsing, rendering, and +// exit-code taxonomy. +package ocxcli + +import ( + "fmt" + "strconv" + "strings" + + "github.com/lidge-jun/opencodex/go/internal/jsonwire" +) + +// accessUsage mirrors ACCESS_USAGE in src/cli/access.ts; the api-key alias +// reuses it verbatim because the TS owner routes `api-key` through +// handleAccessCommand(["key", ...argv]). +const accessUsage = `Usage: + ocx access key [list] [--json] + ocx access key create [name] [--json] + ocx access key rotate [--json] + ocx access key rotate commit [--json] + ocx access key rotate abort [--json] + ocx access key remove --yes [--json] + ocx access endpoints [--json] + ocx access models [--json] + ocx access test [--protocol ] [--json]` + +// runAccess implements `ocx access` (and, through runApiKey, `ocx api-key`). +// argv carries only this command's own arguments. +func runAccess(args []string, deps Deps) int { + err := handleMgmtAccess(args, deps) + if err != nil { + return reportManagementFailure(deps, err) + } + return ExitOK +} + +// runApiKey mirrors the TypeScript dispatch alias: `api-key ` is +// exactly `access key `. +func runApiKey(args []string, deps Deps) int { + return runAccess(append([]string{"key"}, args...), deps) +} + +func handleMgmtAccess(argv []string, deps Deps) error { + sub := "key" + rest := argv + if len(argv) > 0 { + sub = argv[0] + rest = argv[1:] + } + switch sub { + case "key", "keys": + return mgmtAccessKey(rest, deps) + case "endpoints": + return mgmtAccessEndpoints(rest, deps) + case "models": + return mgmtAccessModels(rest, deps) + case "test": + return mgmtAccessTest(rest, deps) + default: + return managementCliUsage("unknown access command "+sub, accessUsage) + } +} + +func mgmtAccessKey(argv []string, deps Deps) error { + args := append([]string(nil), argv...) + action := "list" + if len(args) > 0 { + action = strings.ToLower(args[0]) + args = args[1:] + } + wantsJSON := takeMgmtFlag(&args, "--json") + switch action { + case "list": + if err := rejectMgmtArgs(args, accessUsage); err != nil { + return err + } + body, rawText, _, err := managementRequest(deps, "GET", "/api/keys", "") + if err != nil { + return err + } + lines := []string{"No API access keys configured."} + keys := mgmtKeyRows(body) + if len(keys) > 0 { + lines = mgmtFormatKeyRows(body, keys) + } + printManagementData(deps, body, rawText, wantsJSON, lines) + return nil + case "create": + name := "default" + if len(args) > 0 { + name = args[0] + args = args[1:] + } + if err := rejectMgmtArgs(args, accessUsage); err != nil { + return err + } + result, _, _, err := managementRequest(deps, "POST", "/api/keys", `{"name":`+quoteJSONString(name)+`}`) + if err != nil { + return err + } + resultName := fieldString(result, "name") + if resultName == "" { + resultName = name + } + printManagementData(deps, result, "", wantsJSON, []string{ + fmt.Sprintf("Created API key %s (%s).", resultName, fieldString(result, "id")), + fmt.Sprintf("Key (shown once): %s", fieldString(result, "key")), + }) + return nil + case "rotate": + return mgmtAccessKeyRotate(args, wantsJSON, deps) + case "remove", "delete": + id := "" + if len(args) > 0 { + id = args[0] + args = args[1:] + } + yes := takeMgmtFlag(&args, "--yes") + if id == "" { + return managementCliUsage("key id is required", accessUsage) + } + if !yes { + return managementCliUsage("remove requires --yes", accessUsage) + } + if err := rejectMgmtArgs(args, accessUsage); err != nil { + return err + } + result, _, _, err := managementRequest(deps, "DELETE", "/api/keys", `{"id":`+quoteJSONString(id)+`}`) + if err != nil { + return err + } + printManagementData(deps, result, "", wantsJSON, []string{fmt.Sprintf("Removed API key %s.", id)}) + return nil + default: + return managementCliUsage("unknown key command "+action, accessUsage) + } +} + +func mgmtAccessKeyRotate(args []string, wantsJSON bool, deps Deps) error { + operation := "start" + if len(args) > 0 && (args[0] == "commit" || args[0] == "abort") { + operation = args[0] + args = args[1:] + } + id := "" + if len(args) > 0 { + id = args[0] + args = args[1:] + } + if id == "" { + return managementCliUsage("key id is required", accessUsage) + } + if operation == "start" { + if err := rejectMgmtArgs(args, accessUsage); err != nil { + return err + } + result, _, _, err := managementRequest(deps, "POST", "/api/keys/rotate", `{"id":`+quoteJSONString(id)+`}`) + if err != nil { + return err + } + printManagementData(deps, result, "", wantsJSON, []string{ + fmt.Sprintf("Started rotation for API key %s.", id), + fmt.Sprintf("New key (shown once): %s", fieldString(result, "key")), + fmt.Sprintf("After the client accepts it, commit with rotation id %s.", fieldString(result, "rotationId")), + }) + return nil + } + rotationID := "" + if len(args) > 0 { + rotationID = args[0] + args = args[1:] + } + if rotationID == "" { + return managementCliUsage("rotation id is required", accessUsage) + } + if err := rejectMgmtArgs(args, accessUsage); err != nil { + return err + } + body := `{"id":` + quoteJSONString(id) + `,"rotationId":` + quoteJSONString(rotationID) + `}` + method, path := "POST", "/api/keys/rotate/commit" + if operation == "abort" { + method, path = "DELETE", "/api/keys/rotate" + } + result, _, _, err := managementRequest(deps, method, path, body) + if err != nil { + return err + } + verb := "Committed" + if operation == "abort" { + verb = "Aborted" + } + printManagementData(deps, result, "", wantsJSON, []string{fmt.Sprintf("%s rotation for API key %s.", verb, id)}) + return nil +} + +func mgmtAccessEndpoints(argv []string, deps Deps) error { + args := append([]string(nil), argv...) + wantsJSON := takeMgmtFlag(&args, "--json") + if err := rejectMgmtArgs(args, accessUsage); err != nil { + return err + } + result, _, _, err := managementRequest(deps, "GET", "/api/keys", "") + if err != nil { + return err + } + filtered := jsonwire.ObjectValue() + if result != nil && result.Kind() == jsonwire.Object { + for _, member := range result.Members() { + if strings.HasSuffix(member.Key, "Endpoint") || member.Key == "baseUrl" || member.Key == "endpoint" { + filtered.Set(member.Key, member.Value) + } + } + } + lines := []string{} + for _, member := range filtered.Members() { + lines = append(lines, fmt.Sprintf("%s: %s", member.Key, jsString(member.Value))) + } + printManagementData(deps, filtered, "", wantsJSON, lines) + return nil +} + +func mgmtAccessModels(argv []string, deps Deps) error { + args := append([]string(nil), argv...) + wantsJSON := takeMgmtFlag(&args, "--json") + if err := rejectMgmtArgs(args, accessUsage); err != nil { + return err + } + result, _, _, err := managementRequest(deps, "GET", "/v1/models", "") + if err != nil { + return err + } + lines := []string{} + if data := fieldArray(result, "data"); data != nil { + for _, row := range data.Elements() { + if row == nil || row.Kind() != jsonwire.Object { + continue + } + id := fieldString(row, "id") + ownedBy := "" + if field := row.Find("owned_by"); field != nil { + if field.Kind() == jsonwire.String { + ownedBy = field.String() + } else if field.Kind() == jsonwire.Null { + ownedBy = "" + } + } + line := id + " " + ownedBy + lines = append(lines, strings.TrimRight(line, " ")) + } + } + printManagementData(deps, result, "", wantsJSON, lines) + return nil +} + +func mgmtAccessTest(argv []string, deps Deps) error { + args := append([]string(nil), argv...) + model := "" + if len(args) > 0 { + model = args[0] + args = args[1:] + } + wantsJSON := takeMgmtFlag(&args, "--json") + protocol, _, err := takeMgmtOption(&args, "--protocol") + if err != nil { + return err + } + if protocol == "" { + protocol = "chat" + } + if model == "" { + return managementCliUsage("model is required", accessUsage) + } + if protocol != "chat" && protocol != "responses" && protocol != "messages" { + return managementCliUsage("--protocol must be chat, responses, or messages", accessUsage) + } + if err := rejectMgmtArgs(args, accessUsage); err != nil { + return err + } + path := "/v1/chat/completions" + requestBody := `{"model":` + quoteJSONString(model) + `,"messages":[{"role":"user","content":"Reply with OK."}],"max_tokens":16,"stream":false}` + if protocol == "responses" { + path = "/v1/responses" + requestBody = `{"model":` + quoteJSONString(model) + `,"input":"Reply with OK.","max_output_tokens":16}` + } else if protocol == "messages" { + path = "/v1/messages" + requestBody = `{"model":` + quoteJSONString(model) + `,"messages":[{"role":"user","content":"Reply with OK."}],"max_tokens":16}` + } + result, _, _, reqErr := managementRequest(deps, "POST", path, requestBody) + if reqErr != nil { + return reqErr + } + printManagementData(deps, result, "", wantsJSON, []string{fmt.Sprintf("%s: %s request succeeded.", model, protocol)}) + return nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// key-table rendering — port of formatKeyRows in src/cli/access.ts. + +func mgmtKeyRows(body *jsonwire.Value) []*jsonwire.Value { + if body == nil || body.Kind() != jsonwire.Object { + return nil + } + array := fieldArray(body, "keys") + if array == nil { + return nil + } + var rows []*jsonwire.Value + for _, element := range array.Elements() { + if element != nil && element.Kind() == jsonwire.Object { + rows = append(rows, element) + } + } + return rows +} + +// mgmtFormatKeyRows renders the key table with usage-column semantics: an +// ambiguous key prints one marker spanning both numeric columns; numeric cells +// use toLocaleString("en-US"); the data-set footer prints once below a blank +// line. +func mgmtFormatKeyRows(body *jsonwire.Value, keys []*jsonwire.Value) []string { + cells := [][]string{{"ID", "NAME", "PREFIX", "REQ 7D", "TOTAL", "LAST USED"}} + for _, entry := range keys { + usage := entry.Find("usage") + if usage == nil || usage.Kind() != jsonwire.Object { + usage = jsonwire.ObjectValue() + } + ambiguous := false + if field := usage.Find("ambiguous"); field != nil && field.Kind() == jsonwire.Bool { + ambiguous = field.Bool() + } + requests7d := usage.Find("requests7d") + totalRequests := usage.Find("totalRequests") + lastUsedAt := usage.Find("lastUsedAt") + cells = append(cells, []string{ + fieldString(entry, "id"), + fieldString(entry, "name"), + fieldString(entry, "prefix"), + ambiguousOrNumber(ambiguous, requests7d), + ambiguousOrEmptyNumber(ambiguous, totalRequests), + ambiguousOrLastUsed(ambiguous, lastUsedAt), + }) + } + widths := make([]int, len(cells[0])) + for _, row := range cells { + for i, cell := range row { + if len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + lines := []string{} + for _, row := range cells { + parts := make([]string, 0, len(row)) + for i, cell := range row { + parts = append(parts, cell+strings.Repeat(" ", widths[i]-len(cell))) + } + lines = append(lines, strings.TrimRight(strings.Join(parts, " "), " ")) + } + footer := []string{} + if attribution := fieldString(body, "attributionSince"); attribution != "" { + footer = append(footer, "attribution since "+attribution) + } + if field := body.Find("historyTruncated"); field != nil && field.Kind() == jsonwire.Bool && field.Bool() { + footer = append(footer, "older history truncated") + } + for _, entry := range keys { + usage := entry.Find("usage") + if usage == nil || usage.Kind() != jsonwire.Object { + continue + } + if field := usage.Find("ambiguous"); field != nil && field.Kind() == jsonwire.Bool && field.Bool() { + footer = append(footer, "ambiguous: two configured keys share an id, so per-key totals do not exist") + break + } + } + if len(footer) > 0 { + lines = append(lines, "") + lines = append(lines, footer...) + } + return lines +} + +func ambiguousOrNumber(ambiguous bool, value *jsonwire.Value) string { + if ambiguous { + return "ambiguous" + } + return usageNumberCell(value) +} + +func ambiguousOrEmptyNumber(ambiguous bool, value *jsonwire.Value) string { + if ambiguous { + return "" + } + return usageNumberCell(value) +} + +func ambiguousOrLastUsed(ambiguous bool, value *jsonwire.Value) string { + if ambiguous { + return "" + } + if value != nil && value.Kind() == jsonwire.String { + return value.String() + } + return "never" +} + +func usageNumberCell(value *jsonwire.Value) string { + if value != nil && value.Kind() == jsonwire.Number { + number, err := strconv.ParseFloat(value.NumberRaw(), 64) + if err == nil { + return formatENUSNumber(number) + } + } + return "-" +} + +// fieldString returns an object member's string payload, or "" when absent or +// not a string. +func fieldString(object *jsonwire.Value, key string) string { + if object == nil || object.Kind() != jsonwire.Object { + return "" + } + field := object.Find(key) + if field == nil || field.Kind() != jsonwire.String { + return "" + } + return field.String() +} + +// fieldArray returns an object member's array, or nil when absent/not an array. +func fieldArray(object *jsonwire.Value, key string) *jsonwire.Value { + if object == nil || object.Kind() != jsonwire.Object { + return nil + } + field := object.Find(key) + if field == nil || field.Kind() != jsonwire.Array { + return nil + } + return field +} diff --git a/go/internal/ocxcli/account_auth_cmd.go b/go/internal/ocxcli/account_auth_cmd.go new file mode 100644 index 0000000000..d9279fcb2a --- /dev/null +++ b/go/internal/ocxcli/account_auth_cmd.go @@ -0,0 +1,809 @@ +// ocx account login/reauth/code/cancel/reset-credits — the OAuth device-flow +// surface of the management API, Go-native port of src/cli/account-auth.ts on +// the runtime-api client (RuntimeApiError taxonomy, CliUsageError exit 2, the +// synchronous flow-block write, secret-only stdin reading). These are the +// headless login paths the ticket's "OAuth device flows" slice names; the +// top-level `ocx login` browser/key interactive flow and `ocx setup` stay with +// the TypeScript owner because neither can be byte-oracled without a live +// upstream OAuth round-trip or an interactive menu. +package ocxcli + +import ( + "bufio" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/lidge-jun/opencodex/go/internal/jsonwire" +) + +// accountAuthNames are the codex-family ids that share the codex-auth routes. +var accountAuthCodexNames = map[string]bool{ + "openai": true, "codex": true, "chatgpt": true, +} + +// device-native providers: kimi/nous/github-copilot already run device grants, +// so --device is accepted as a no-op for them. +var accountAuthDeviceNative = map[string]bool{ + "kimi": true, "nous": true, "github-copilot": true, +} + +const accountAuthArgvWarning = "warning: the authorization code was passed as a command-line argument, so it is now in your shell history and was visible in the process list while this ran. Pipe it on stdin instead, or pass `-` to read from stdin." + +// authCliUsageError mirrors CliUsageError: a usage-shaped failure whose message +// is printed as "Error: " (with the usage block when set) at exit 2. +type authCliUsageError struct { + message string + usage string +} + +func (e authCliUsageError) Error() string { return e.message } + +// authRuntimeAPIError mirrors RuntimeApiError: message already composed via +// responseMessage; the status maps to the 4/5/1 exit taxonomy. +type authRuntimeAPIError struct { + message string + status int +} + +func (e authRuntimeAPIError) Error() string { return e.message } + +// authTrimmedString mirrors stringField. +func authTrimmedString(object *jsonwire.Value, key string) string { + if object == nil || object.Kind() != jsonwire.Object { + return "" + } + field := object.Find(key) + if field == nil || field.Kind() != jsonwire.String { + return "" + } + return strings.TrimSpace(field.String()) +} + +// authResponseMessage mirrors responseMessage in runtime-api.ts. +func authResponseMessage(body *jsonwire.Value, rawText string, status int) string { + if rawText != "" { + trimmed := strings.TrimSpace(rawText) + if trimmed != "" { + return authTruncate(trimmed, 400) + } + } + if body == nil || body.Kind() != jsonwire.Object { + return fmt.Sprintf("Management request failed (%d)", status) + } + primary := "" + for _, key := range []string{"error", "message", "detail"} { + if value := authTrimmedString(body, key); value != "" { + primary = value + break + } + } + if primary == "" { + primary = fmt.Sprintf("Management request failed (%d)", status) + } + parts := []string{primary} + for _, key := range []string{"reason", "hint"} { + if value := authTrimmedString(body, key); value != "" && value != primary { + parts = append(parts, key+": "+value) + } + } + return authTruncate(strings.Join(parts, "\n"), 1200) +} + +func authTruncate(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +// authBaseURL mirrors runtimeBaseUrl: no explicit base URL means the +// identity-checked live proxy, with the runtime-api wording when absent. +func authBaseURL(acc accountDeps) (string, error) { + if state, found := liveProxyEndpoint(acc.deps); found { + return baseURL(state), nil + } + return "", authRuntimeAPIError{message: "Proxy is not running. Start it with: ocx start", status: 503} +} + +// authRequest performs one management request with the runtime-api client +// contract: body JSON (nil = none), non-ok responses become RuntimeApiError +// with the responseMessage composition, transport failures name the cause. +func authRequest(acc accountDeps, baseURL, method, path string, body *jsonwire.Value) (*jsonwire.Value, error) { + client := acc.httpClientOr() + if client == nil { + client = defaults(acc.deps).HTTPClient + } + var reader io.Reader + if body != nil { + encoded, err := body.Encode() + if err != nil { + return nil, authRuntimeAPIError{message: "Management API is unreachable: " + err.Error(), status: 503} + } + reader = strings.NewReader(string(encoded)) + } + request, err := newRequestWithHeaders(method, baseURL+path, reader) + if err != nil { + return nil, authRuntimeAPIError{message: "Management API is unreachable: " + err.Error(), status: 503} + } + response, doErr := client.Do(request) + if doErr != nil { + return nil, authRuntimeAPIError{message: "Management API is unreachable: " + doErr.Error(), status: 503} + } + defer response.Body.Close() + raw := make([]byte, 0, 4096) + buffer := make([]byte, 32*1024) + for { + n, readErr := response.Body.Read(buffer) + raw = append(raw, buffer[:n]...) + if readErr != nil { + break + } + } + // runtimeRequest parses the text body first and only falls back to the raw + // text when JSON parsing fails, so an object body's error/reason/hint keys + // are read structurally rather than printed verbatim. + rawText := string(raw) + bodyValue, parseErr := jsonwire.Parse(raw) + if parseErr != nil { + bodyValue = nil + } else { + rawText = "" + } + if response.StatusCode < 200 || response.StatusCode > 299 { + message := authResponseMessage(bodyValue, rawText, response.StatusCode) + return nil, authRuntimeAPIError{message: message, status: response.StatusCode} + } + return bodyValue, nil +} + +// newRequestWithHeaders assembles the running-proxy request (Content-Type and +// the admin token header) exactly like runtimeRequest via runningProxyUpdateHeaders. +func newRequestWithHeaders(method, url string, reader io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, url, reader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if token := configuredUsageAdminToken(); token != "" { + req.Header.Set("X-OpenCodex-API-Key", token) + } + return req, nil +} + +// authTakeOption mirrors takeOption: `${flag} requires a value` is a usage +// error without a usage block. +func authTakeOption(args *[]string, flag string) (string, bool) { + index := -1 + for i, arg := range *args { + if arg == flag { + index = i + break + } + } + if index == -1 { + return "", false + } + value := (*args)[index+1] + if index+1 >= len(*args) || strings.HasPrefix(value, "--") { + return "", true // caller reports requires-a-value + } + *args = append((*args)[:index], (*args)[index+2:]...) + return value, false +} + +// authTakeOptionWithSyntax mirrors takeOptionWithSyntax: both `--flag value` +// and `--flag=value`, rejecting duplicates and empty inline values. +func authTakeOptionWithSyntax(args *[]string, flag string) (value string, inline bool, ok bool, err error) { + occurrences := 0 + for _, arg := range *args { + if arg == flag || strings.HasPrefix(arg, flag+"=") { + occurrences++ + } + } + if occurrences > 1 { + return "", false, false, authCliUsageError{message: flag + " was given more than once"} + } + inlineIndex := -1 + for i, arg := range *args { + if strings.HasPrefix(arg, flag+"=") { + inlineIndex = i + break + } + } + if inlineIndex != -1 { + raw := (*args)[inlineIndex] + *args = append((*args)[:inlineIndex], (*args)[inlineIndex+1:]...) + value = raw[len(flag)+1:] + if value == "" { + return "", false, false, authCliUsageError{message: flag + " requires a value"} + } + return value, true, true, nil + } + value, missing := authTakeOption(args, flag) + if missing { + return "", false, false, authCliUsageError{message: flag + " requires a value"} + } + return value, false, value != "", nil +} + +// authTakeFlag mirrors takeFlag. +func authTakeFlag(args *[]string, flag string) bool { + for i, arg := range *args { + if arg == flag { + *args = append((*args)[:i], (*args)[i+1:]...) + return true + } + } + return false +} + +// authSecretOptions are the credential-carrying options redacted when an +// unexpected-argument error reports leftovers. +var authSecretOptions = []string{"--code", "--headers", "--token", "--admin-token", "--pairing-code", "--credential-env", "--admin-token-env", "--pairing-code-env"} + +func authIsSecretOption(flag string) bool { + for _, option := range authSecretOptions { + if flag == option { + return true + } + } + return false +} + +// authRedactSecretArgs mirrors redactSecretArgs. +func authRedactSecretArgs(args []string, redactValues bool) []string { + var out []string + for index := 0; index < len(args); index++ { + arg := args[index] + inline := "" + for _, option := range authSecretOptions { + if strings.HasPrefix(arg, option+"=") { + inline = option + break + } + } + if inline != "" { + out = append(out, inline+"=") + continue + } + if authIsSecretOption(arg) { + out = append(out, arg) + valueIndex := index + 1 + if valueIndex < len(args) && args[valueIndex] == "--" { + out = append(out, "--") + valueIndex++ + } + if valueIndex < len(args) { + out = append(out, "") + index = valueIndex + } + continue + } + if redactValues && !strings.HasPrefix(arg, "-") { + out = append(out, "") + } else { + out = append(out, arg) + } + } + return out +} + +// authRejectArgs mirrors rejectArgs: leftover args become a usage error naming +// the redacted list. +func authRejectArgs(args []string, usage string, redactValues bool) error { + if len(args) == 0 { + return nil + } + shown := authRedactSecretArgs(args, redactValues) + return authCliUsageError{message: "Unexpected argument(s): " + strings.Join(shown, " "), usage: usage} +} + +// authReadSecretLine reads one line from the process stdin, mirroring +// readSecretLine: resolve on newline or EOF, empty input is a usage error. +func authReadSecretLine(acc accountDeps, label string) (string, error) { + var input io.Reader = os.Stdin + if acc.stdin != nil { + input = acc.stdin + } + reader := bufio.NewReader(input) + line, err := reader.ReadString('\n') + if err != nil && len(line) == 0 && !errors.Is(err, io.EOF) { + return "", authCliUsageError{message: label + " input was empty"} + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + return "", authCliUsageError{message: label + " input was empty"} + } + return strings.TrimSpace(line), nil +} + +// authResolveCode mirrors resolveCode. +func authResolveCode(acc accountDeps, suppliedValue string, supplied bool, required bool) (string, error) { + if supplied && suppliedValue != "-" { + reportAccountStderrLine(acc, accountAuthArgvWarning) + return suppliedValue, nil + } + if !supplied && !required { + return "", nil + } + // A TTY gets a paste prompt on stderr; a pipe does not. + if acc.stdinIsTTY { + reportAccountStderrLine(acc, "Paste the redirect URL or authorization code, then press Enter:") + } + return authReadSecretLine(acc, "authorization code") +} + +// authPrintData mirrors printData: JSON pretty when wantsJson or when no lines +// were provided, otherwise one line each. +func authPrintData(acc accountDeps, value *jsonwire.Value, wantsJSON bool, lines []string) { + if wantsJSON || len(lines) == 0 { + printPrettyJSON(acc.deps, value) + return + } + for _, line := range lines { + fmt.Fprintln(acc.deps.Stdout, line) + } +} + +// authWriteStdoutFully mirrors writeStdoutFully (one atomic block; partial +// writes loop, a failed write is a usage error). +func authWriteStdoutFully(acc accountDeps, text string) error { + if acc.deps.Stdout == nil { + acc.deps.Stdout = os.Stdout + } + writer := &countingWriter{w: acc.deps.Stdout} + _, err := io.WriteString(writer, text) + if err != nil { + return authCliUsageError{message: "failed to write login instructions to stdout"} + } + return nil +} + +type countingWriter struct { + w io.Writer +} + +func (c *countingWriter) Write(p []byte) (int, error) { return c.w.Write(p) } + +// authRunAction mirrors runCliAction: usage errors exit 2, RuntimeApiError +// status maps 404→4, 409→5, everything else →1. +func authRunAction(acc accountDeps, action func() error) int { + err := action() + if err == nil { + return 0 + } + var usageErr authCliUsageError + if errors.As(err, &usageErr) { + fmt.Fprintln(acc.deps.Stderr, "Error: "+usageErr.message) + if usageErr.usage != "" { + fmt.Fprintln(acc.deps.Stderr, usageErr.usage) + } + return 2 + } + var apiErr authRuntimeAPIError + if errors.As(err, &apiErr) { + fmt.Fprintln(acc.deps.Stderr, "Error: "+apiErr.message) + switch apiErr.status { + case 404: + return accountExitMissing + case 409: + return accountExitConflict + default: + return 1 + } + } + fmt.Fprintln(acc.deps.Stderr, "Error: "+err.Error()) + return 1 +} + +// accountAuthLogin mirrors login() in account-auth.ts. +func accountAuthLogin(sub string, argv []string, acc accountDeps) error { + args := make([]string, len(argv)) + copy(args, argv) + provider := "" + if len(args) > 0 { + provider = strings.ToLower(strings.TrimSpace(args[0])) + args = args[1:] + } + reauth := sub == "reauth" || authTakeFlag(&args, "--reauth") + wantsJSON := authTakeFlag(&args, "--json") + noWait := authTakeFlag(&args, "--no-wait") + device := authTakeFlag(&args, "--device") + id, idMissing := authTakeOption(&args, "--id") + if idMissing { + return authCliUsageError{message: "--id requires a value"} + } + codeValue, _, codeSupplied, codeErr := authTakeOptionWithSyntax(&args, "--code") + if codeErr != nil { + return codeErr + } + if provider == "" { + return authCliUsageError{message: "provider is required", usage: accountAuthUsage} + } + if err := authRejectArgs(args, accountAuthUsage, false); err != nil { + return err + } + if device && !accountAuthCodexNames[provider] && !accountAuthDeviceNative[provider] { + return authCliUsageError{message: "--device is not supported for provider '" + provider + "'", usage: accountAuthUsage} + } + code, err := authResolveCode(acc, codeValue, codeSupplied, false) + if err != nil { + return err + } + + baseURL, err := authBaseURL(acc) + if err != nil { + return err + } + if accountAuthCodexNames[provider] { + body := jsonwire.ObjectValue() + if id != "" { + body.Set("id", jsonwire.StringValue(id)) + } + if reauth { + body.Set("reauth", jsonwire.BoolValue(true)) + } + if device { + body.Set("device", jsonwire.BoolValue(true)) + } + startValue, err := authRequest(acc, baseURL, "POST", "/api/codex-auth/login", body) + if err != nil { + return err + } + start := startValue + if !wantsJSON { + var block []string + if line := flowBlockURL(start); line != "" { + block = append(block, line) + } + if text := authTrimmedString(start, "deviceCode"); text != "" { + block = append(block, "Device code: "+text) + } + if text := authTrimmedString(start, "instructions"); text != "" { + block = append(block, text) + } + if text := authTrimmedString(start, "flowId"); text != "" { + block = append(block, "Flow: "+text) + } + if len(block) > 0 { + if err := authWriteStdoutFully(acc, strings.Join(block, "\n")+"\n"); err != nil { + return err + } + } + } + flowID := authTrimmedString(start, "flowId") + if code != "" && flowID != "" { + codeBody := jsonwire.ObjectValue() + codeBody.Set("flowId", jsonwire.StringValue(flowID)) + codeBody.Set("input", jsonwire.StringValue(code)) + if _, err := authRequest(acc, baseURL, "POST", "/api/codex-auth/login/code", codeBody); err != nil { + return err + } + } + if noWait { + if wantsJSON { + authPrintData(acc, start, true, nil) + } + return nil + } + if flowID == "" { + return authCliUsageError{message: "login did not return a flow id"} + } + return authPollCodexLogin(acc, baseURL, flowID, id, reauth, device, wantsJSON) + } + + if id != "" && !reauth { + return authCliUsageError{message: "--id is only valid with --reauth for provider OAuth accounts", usage: accountAuthUsage} + } + body := jsonwire.ObjectValue() + body.Set("provider", jsonwire.StringValue(provider)) + if reauth { + body.Set("addAccount", jsonwire.BoolValue(false)) + if id != "" { + body.Set("accountId", jsonwire.StringValue(id)) + body.Set("reauth", jsonwire.BoolValue(true)) + } + } else { + body.Set("addAccount", jsonwire.BoolValue(true)) + } + startValue, err := authRequest(acc, baseURL, "POST", "/api/oauth/login", body) + if err != nil { + return err + } + start := startValue + if !wantsJSON { + var block []string + if line := flowBlockURL(start); line != "" { + block = append(block, line) + } + if text := authTrimmedString(start, "instructions"); text != "" { + block = append(block, text) + } + if text := authTrimmedString(start, "deviceCode"); text != "" { + block = append(block, "Device code: "+text) + } + if len(block) > 0 { + if err := authWriteStdoutFully(acc, strings.Join(block, "\n")+"\n"); err != nil { + return err + } + } + } + if code != "" { + codeBody := jsonwire.ObjectValue() + codeBody.Set("provider", jsonwire.StringValue(provider)) + codeBody.Set("input", jsonwire.StringValue(code)) + if _, err := authRequest(acc, baseURL, "POST", "/api/oauth/login/code", codeBody); err != nil { + return err + } + } + if noWait { + if wantsJSON { + authPrintData(acc, start, true, nil) + } + return nil + } + return authPollOAuthLogin(acc, baseURL, provider, wantsJSON) +} + +// flowBlockURL mirrors the "Open this URL to sign in:\n" line. +func flowBlockURL(start *jsonwire.Value) string { + url := authTrimmedString(start, "url") + if url == "" { + return "" + } + return "Open this URL to sign in:\n" + url +} + +// authPollCodexLogin / authPollOAuthLogin mirror the 2s polling loops; the +// device-flow grants make these slow on purpose (15-minute grant), so the +// byte-diff oracle only exercises the --no-wait and error terminations. +func authPollCodexLogin(acc accountDeps, baseURL, flowID, id string, reauth, device, wantsJSON bool) error { + maxAttempts := 150 + if device { + maxAttempts = 480 + } + for attempt := 0; attempt < maxAttempts; attempt++ { + authSleep(2) + query := "/api/codex-auth/login-status?flowId=" + urlQueryEscape(flowID) + if id != "" { + query += "&accountId=" + urlQueryEscape(id) + } + if reauth { + query += "&reauth=1" + } + state, err := authRequest(acc, baseURL, "GET", query, nil) + if err != nil { + return err + } + status := authTrimmedString(state, "status") + if status == "done" { + var lines []string + line := "Logged in." + if email := authTrimmedString(state, "email"); email != "" { + line = "Logged in as " + email + "." + } + lines = append(lines, line) + authPrintData(acc, state, wantsJSON, lines) + if !wantsJSON { + warnIfCodexCatalogRefreshPending(acc, state) + } + return nil + } + if status == "error" || status == "expired" { + text := authTrimmedString(state, "error") + if text == "" { + text = "login " + status + } + return authCliUsageError{message: text} + } + } + return authCliUsageError{message: "login timed out"} +} + +func authPollOAuthLogin(acc accountDeps, baseURL, provider string, wantsJSON bool) error { + for attempt := 0; attempt < 100; attempt++ { + authSleep(2) + state, err := authRequest(acc, baseURL, "GET", "/api/oauth/status?provider="+urlQueryEscape(provider), nil) + if err != nil { + return err + } + if text := authTrimmedString(state, "error"); text != "" { + return authCliUsageError{message: text} + } + if field := state.Find("loggedIn"); field != nil && field.Kind() == jsonwire.Bool && field.Bool() { + authPrintData(acc, state, wantsJSON, []string{"Logged in to " + provider + "."}) + return nil + } + } + return authCliUsageError{message: "login timed out"} +} + +func authSleep(seconds int) { + time.Sleep(time.Duration(seconds) * time.Second) +} + +// accountAuthCode mirrors code() in account-auth.ts. +func accountAuthCode(argv []string, acc accountDeps) error { + args := make([]string, len(argv)) + copy(args, argv) + provider := "" + if len(args) > 0 { + provider = strings.ToLower(strings.TrimSpace(args[0])) + args = args[1:] + } + wantsJSON := authTakeFlag(&args, "--json") + flowID, flowMissing := authTakeOption(&args, "--flow") + if flowMissing { + return authCliUsageError{message: "--flow requires a value"} + } + codeValue, _, codeSupplied, codeErr := authTakeOptionWithSyntax(&args, "--code") + if codeErr != nil { + return codeErr + } + positional := "" + hasPositional := false + if len(args) > 0 && !strings.HasPrefix(args[0], "--") { + positional = args[0] + hasPositional = true + args = args[1:] + } + if provider == "" { + return authCliUsageError{message: "provider is required", usage: accountAuthUsage} + } + if err := authRejectArgs(args, accountAuthUsage, true); err != nil { + return err + } + if codeSupplied && hasPositional { + return authCliUsageError{message: "pass the code either positionally or with --code, not both", usage: accountAuthUsage} + } + suppliedValue := "" + supplied := codeSupplied + if !supplied && hasPositional { + suppliedValue = positional + supplied = true + } else if codeSupplied { + suppliedValue = codeValue + } + input, err := authResolveCode(acc, suppliedValue, supplied, true) + if err != nil { + return err + } + if input == "" { + return authCliUsageError{message: "provider and redirect/code are required", usage: accountAuthUsage} + } + codex := accountAuthCodexNames[provider] + path := "/api/oauth/login/code" + if codex { + path = "/api/codex-auth/login/code" + } + baseURL, err := authBaseURL(acc) + if err != nil { + return err + } + if codex && flowID == "" { + return authCliUsageError{message: "Codex login code requires --flow ", usage: accountAuthUsage} + } + body := jsonwire.ObjectValue() + if codex { + body.Set("flowId", jsonwire.StringValue(flowID)) + } else { + body.Set("provider", jsonwire.StringValue(provider)) + } + body.Set("input", jsonwire.StringValue(input)) + result, err := authRequest(acc, baseURL, "POST", path, body) + if err != nil { + return err + } + authPrintData(acc, result, wantsJSON, []string{"Login code submitted."}) + return nil +} + +// accountAuthCancel mirrors cancel() in account-auth.ts. +func accountAuthCancel(argv []string, acc accountDeps) error { + args := make([]string, len(argv)) + copy(args, argv) + provider := "" + if len(args) > 0 { + provider = strings.ToLower(strings.TrimSpace(args[0])) + args = args[1:] + } + wantsJSON := authTakeFlag(&args, "--json") + flowID, flowMissing := authTakeOption(&args, "--flow") + if flowMissing { + return authCliUsageError{message: "--flow requires a value"} + } + if provider == "" { + return authCliUsageError{message: "provider is required", usage: accountAuthUsage} + } + if err := authRejectArgs(args, accountAuthUsage, false); err != nil { + return err + } + codex := accountAuthCodexNames[provider] + path := "/api/oauth/login/cancel" + body := jsonwire.ObjectValue() + if codex { + path = "/api/codex-auth/login/cancel" + body.Set("flowId", jsonwire.StringValue(flowID)) + } else { + body.Set("provider", jsonwire.StringValue(provider)) + } + baseURL, err := authBaseURL(acc) + if err != nil { + return err + } + result, err := authRequest(acc, baseURL, "POST", path, body) + if err != nil { + return err + } + authPrintData(acc, result, wantsJSON, []string{"Cancelled " + provider + " login."}) + return nil +} + +// accountAuthResetCredits mirrors resetCredits(). +func accountAuthResetCredits(argv []string, acc accountDeps) error { + args := make([]string, len(argv)) + copy(args, argv) + rawID := "" + if len(args) > 0 { + rawID = strings.TrimSpace(args[0]) + args = args[1:] + } + wantsJSON := authTakeFlag(&args, "--json") + consume := authTakeFlag(&args, "--consume") + yes := authTakeFlag(&args, "--yes") + if rawID == "" { + return authCliUsageError{message: "account id is required", usage: accountAuthUsage} + } + if consume && !yes { + return authCliUsageError{message: "consuming a reset credit requires --yes", usage: accountAuthUsage} + } + if err := authRejectArgs(args, accountAuthUsage, false); err != nil { + return err + } + accountID := rawID + if rawID == "main" { + accountID = mainAccountID + } + baseURL, err := authBaseURL(acc) + if err != nil { + return err + } + var result *jsonwire.Value + if consume { + body := jsonwire.ObjectValue() + body.Set("accountId", jsonwire.StringValue(accountID)) + result, err = authRequest(acc, baseURL, "POST", "/api/codex-auth/reset-credits/consume", body) + } else { + result, err = authRequest(acc, baseURL, "GET", "/api/codex-auth/reset-credits?accountId="+urlQueryEscape(accountID), nil) + } + if err != nil { + return err + } + authPrintData(acc, result, wantsJSON, nil) + return nil +} + +// runAccountAuthCommand is the runAccount-case entry for the device-flow +// subcommands; it returns the exit code from the runCliAction taxonomy. +func runAccountAuthCommand(sub string, argv []string, acc accountDeps) (int, bool) { + var action func() error + switch sub { + case "login", "reauth": + action = func() error { return accountAuthLogin(sub, argv, acc) } + case "code": + action = func() error { return accountAuthCode(argv, acc) } + case "cancel": + action = func() error { return accountAuthCancel(argv, acc) } + case "reset-credits": + action = func() error { return accountAuthResetCredits(argv, acc) } + default: + return 0, false + } + return authRunAction(acc, action), true +} diff --git a/go/internal/ocxcli/account_command.go b/go/internal/ocxcli/account_command.go new file mode 100644 index 0000000000..bee67138ae --- /dev/null +++ b/go/internal/ocxcli/account_command.go @@ -0,0 +1,653 @@ +// ocx account — Go-native port of src/cli/account.ts (the command switch and +// the list/current/use handlers) over the account_runtime data layer. The +// differential oracle feeds one attested fixture proxy and the same +// config.json to the TypeScript CLI and this binary and requires identical +// stdout/stderr and exit codes for every subcommand. +package ocxcli + +import ( + "fmt" + "net/http" + "strings" + + "github.com/lidge-jun/opencodex/go/internal/jsonwire" +) + +// accountFamilyRows is the FamilyRows shape account-api.ts returns. +type accountFamilyRows struct { + rows []*accountRow + activeID string + hasActiveID bool + hasAutoSwitch bool + autoSwitch float64 + status int + errorBody *jsonwire.Value + networkDown bool + transportErr string +} + +func accountRowObject(row *accountRow, includeQuota bool) *jsonwire.Value { + out := jsonwire.ObjectValue() + out.Set("provider", jsonwire.StringValue(row.provider)) + out.Set("type", jsonwire.StringValue(string(row.rowType))) + out.Set("id", jsonwire.StringValue(row.id)) + if row.hasLabel { + out.Set("label", jsonwire.StringValue(row.label)) + } + if row.hasEmail { + out.Set("email", jsonwire.StringValue(row.email)) + } + if row.hasPlan { + out.Set("plan", jsonwire.StringValue(row.plan)) + } + if row.hasMasked { + out.Set("masked", jsonwire.StringValue(row.masked)) + } + out.Set("active", jsonwire.BoolValue(row.active)) + if row.needsReauthSet { + out.Set("needsReauth", jsonwire.BoolValue(row.needsReauth)) + } + if row.rowType == accountTypeCodex { + out.Set("priority", jsonwire.NumberFrom(row.priority)) + out.Set("paused", jsonwire.BoolValue(row.paused)) + if includeQuota { + out.Set("quota", row.quota) + } + } + if row.rowType == accountTypeOAuth { + if row.hasQuota { + out.Set("quota", row.quota) + } + if row.quotaUnavailable { + out.Set("quotaUnavailable", jsonwire.BoolValue(true)) + } + } + return out +} + +func projectQuota(quota *jsonwire.Value) *jsonwire.Value { + if quota == nil || quota.Kind() != jsonwire.Object { + return jsonwire.NullValue() + } + keys := []string{"fiveHourPercent", "fiveHourResetAt", "weeklyPercent", "monthlyPercent", "weeklyResetAt", "monthlyResetAt", "shortPercent", "shortResetAt", "shortWindowSeconds"} + out := jsonwire.ObjectValue() + for _, key := range keys { + field := quota.Find(key) + if field == nil || field.Kind() != jsonwire.Number { + continue + } + if number, err := numberAsFloat(field); err == nil && number == number { + out.Set(key, field) + } + } + return out +} + +// fetchCodexRows mirrors fetchCodexRows in account-api.ts (two parallel GETs). +func fetchCodexRows(client *http.Client, baseURL string, forceRefresh, includeQuota bool) accountFamilyRows { + accountsPath := "/api/codex-auth/accounts" + if forceRefresh { + accountsPath += "?refresh=1" + } + accounts := accountHTTP(client, baseURL, "GET", accountsPath, nil) + active := accountHTTP(client, baseURL, "GET", "/api/codex-auth/active", nil) + rows := accountFamilyRows{status: 200} + if accounts.status != 0 && accounts.status != 200 { + return accountFamilyRows{status: accounts.status, errorBody: accounts.body} + } + if active.status != 0 && active.status != 200 { + return accountFamilyRows{status: active.status, errorBody: active.body} + } + if accounts.status == 0 || active.status == 0 { + transportErr := accounts.transportError + if transportErr == "" { + transportErr = active.transportError + } + return accountFamilyRows{status: 0, networkDown: true, transportErr: transportErr} + } + if field := active.body.Find("activeCodexAccountId"); field != nil && field.Kind() == jsonwire.String { + rows.activeID = field.String() + rows.hasActiveID = true + } + if number, ok := accountNumber(active.body, "autoSwitchThreshold"); ok { + rows.autoSwitch = number + rows.hasAutoSwitch = true + } + accountsArray := activeOrEmptyArray(accounts.body, "accounts") + for _, account := range accountsArray { + row := &accountRow{provider: "openai", rowType: accountTypeCodex} + row.id = objectString(account, "id") + // label = a.alias ?? a.plan ?? a.email (nullish, not empty-coalescing). + aliasValue, aliasSet := objectField(account, "alias") + planValue, planSet := objectField(account, "plan") + emailValue, emailSet := objectField(account, "email") + row.hasPlan = planSet + row.plan = planValue + row.hasEmail = emailSet + row.email = emailValue + if aliasSet { + row.hasLabel = true + row.label = aliasValue + } else if planSet { + row.hasLabel = true + row.label = planValue + } else if emailSet { + row.hasLabel = true + row.label = emailValue + } + row.active = row.id != "" && row.id == rows.activeID + row.needsReauthSet, row.needsReauth = objectBool(account, "needsReauth") + row.hasPriority = true + if number, ok := accountNumber(account, "priority"); ok { + row.priority = number + } + if paused, present := objectBool(account, "paused"); present { + row.paused = paused + } + if includeQuota { + row.quota = projectQuota(account.Find("quota")) + row.hasQuota = true + } + rows.rows = append(rows.rows, row) + } + return rows +} + +// fetchOAuthRows mirrors fetchOAuthRows. +func fetchOAuthRows(client *http.Client, baseURL, name string, withQuota, refreshQuota bool) accountFamilyRows { + query := "" + if withQuota { + query = "?provider=" + urlQueryEscape(name) + ""a=1" + if refreshQuota { + query += "&refresh=1" + } + } else { + query = "?provider=" + urlQueryEscape(name) + } + response := accountHTTP(client, baseURL, "GET", "/api/oauth/accounts"+query, nil) + if response.status == 0 { + return accountFamilyRows{status: 0, networkDown: true, transportErr: response.transportError} + } + if response.status != 200 { + return accountFamilyRows{status: response.status, errorBody: response.body} + } + rows := accountFamilyRows{status: 200} + if field := response.body.Find("activeAccountId"); field != nil && field.Kind() == jsonwire.String { + rows.activeID = field.String() + rows.hasActiveID = true + } + for index, account := range activeOrEmptyArray(response.body, "accounts") { + row := &accountRow{provider: name, rowType: accountTypeOAuth} + row.id = objectString(account, "id") + aliasValue, aliasSet := objectField(account, "alias") + emailValue, emailSet := objectField(account, "email") + row.hasEmail = emailSet + row.email = emailValue + if aliasSet { + row.hasLabel = true + row.label = aliasValue + } else if emailSet { + row.hasLabel = true + row.label = emailValue + } else { + row.hasLabel = true + row.label = fmt.Sprintf("Account %d", index+1) + } + row.active = false + if activeField := account.Find("active"); activeField != nil && activeField.Kind() == jsonwire.Bool { + row.active = activeField.Bool() + } else if row.id != "" { + row.active = row.id == rows.activeID + } + row.needsReauthSet, row.needsReauth = objectBool(account, "needsReauth") + if quota := account.Find("quota"); quota != nil { + row.quota = quota + row.hasQuota = true + } + if unavailable := account.Find("quotaUnavailable"); unavailable != nil && unavailable.Kind() == jsonwire.Bool { + row.quotaUnavailable = unavailable.Bool() + } + rows.rows = append(rows.rows, row) + } + return rows +} + +// fetchKeyRows mirrors fetchKeyRows. +func fetchKeyRows(client *http.Client, baseURL, name string) accountFamilyRows { + response := accountHTTP(client, baseURL, "GET", "/api/providers/keys?name="+urlQueryEscape(name), nil) + if response.status == 0 { + return accountFamilyRows{status: 0, networkDown: true, transportErr: response.transportError} + } + if response.status != 200 { + return accountFamilyRows{status: response.status, errorBody: response.body} + } + rows := accountFamilyRows{status: 200} + if field := response.body.Find("activeId"); field != nil && field.Kind() == jsonwire.String { + rows.activeID = field.String() + rows.hasActiveID = true + } + for _, key := range activeOrEmptyArray(response.body, "keys") { + row := &accountRow{provider: name, rowType: accountTypeAPIKey} + row.id = objectString(key, "id") + labelValue, labelSet := objectField(key, "label") + maskedValue, maskedSet := objectField(key, "masked") + row.hasMasked = maskedSet + row.masked = maskedValue + // row.label = k.label ?? k.masked (nullish). + if labelSet { + row.hasLabel = true + row.label = labelValue + } else if maskedSet { + row.hasLabel = true + row.label = maskedValue + } + row.active = false + if activeField := key.Find("active"); activeField != nil && activeField.Kind() == jsonwire.Bool { + row.active = activeField.Bool() + } else if row.id != "" { + row.active = row.id == rows.activeID + } + rows.rows = append(rows.rows, row) + } + return rows +} + +func fetchAccountRows(client *http.Client, baseURL, name string, rowType AccountType, withQuota, refreshQuota bool) accountFamilyRows { + switch rowType { + case accountTypeCodex: + return fetchCodexRows(client, baseURL, refreshQuota, withQuota) + case accountTypeOAuth: + return fetchOAuthRows(client, baseURL, name, withQuota, refreshQuota) + default: + return fetchKeyRows(client, baseURL, name) + } +} + +// familyFailure maps a FamilyRows error to an exit code, mirroring the +// familyFailure helper in account-extended.ts; nil means no failure. +func accountFamilyFailure(deps Deps, result accountFamilyRows, fallback string) *int { + if result.networkDown { + code := reportProxyUnreachable(deps, result.transportErr) + return &code + } + if result.errorBody != nil { + code := accountAPIError(deps, result.errorBody, fallback, result.status) + return &code + } + return nil +} + +func objectField(object *jsonwire.Value, key string) (value string, present bool) { + if object == nil || object.Kind() != jsonwire.Object { + return "", false + } + field := object.Find(key) + if field == nil || field.Kind() != jsonwire.String { + return "", false + } + return field.String(), true +} + +func objectBool(object *jsonwire.Value, key string) (value bool, present bool) { + if object == nil || object.Kind() != jsonwire.Object { + return false, false + } + field := object.Find(key) + if field == nil || field.Kind() != jsonwire.Bool { + return false, false + } + return field.Bool(), true +} + +func objectString(object *jsonwire.Value, key string) string { + if object == nil || object.Kind() != jsonwire.Object { + return "" + } + field := object.Find(key) + if field == nil || field.Kind() != jsonwire.String { + return "" + } + return field.String() +} + +func activeOrEmptyArray(object *jsonwire.Value, key string) []*jsonwire.Value { + if object == nil || object.Kind() != jsonwire.Object { + return nil + } + field := object.Find(key) + if field == nil || field.Kind() != jsonwire.Array { + return nil + } + return field.Elements() +} + +func firstPresent(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +// urlQueryEscape mirrors encodeURIComponent: every byte outside the RFC 3986 +// unreserved set (A-Z a-z 0-9 - _ . ~) becomes an uppercase %XX escape. +func urlQueryEscape(value string) string { + const hex = "0123456789ABCDEF" + var b strings.Builder + for i := 0; i < len(value); i++ { + c := value[i] + if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~' { + b.WriteByte(c) + continue + } + b.WriteByte('%') + b.WriteByte(hex[c>>4]) + b.WriteByte(hex[c&0xf]) + } + return b.String() +} + +// consumeAccountPositionals strips a leading positional (returned as the +// trimmed name) after all flags were consumed. +func accountShift(args *[]string) string { + if len(*args) == 0 { + return "" + } + value := (*args)[0] + *args = (*args)[1:] + return value +} + +// runAccountList mirrors cmdList in account.ts. +func runAccountList(rest []string, deps accountDeps) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + showAll := consumeAccountFlag(&rest, "--all") + wantsQuota := consumeAccountFlag(&rest, "--quota") + refreshQuota := consumeAccountFlag(&rest, "--refresh") + name := accountShift(&rest) + if leftover := accountLeftoverError(rest); leftover != "" { + reportAccountStderrLine(deps, leftover) + reportAccountUsage(deps, accountUsage) + return 1 + } + raw := loadAccountConfigRaw() + baseURL := resolveAccountBaseURL(deps) + if baseURL == "" { + return reportProxyUnreachable(deps.deps, "") + } + + type target struct { + name string + rowType AccountType + } + var targets []target + if name != "" { + errorText, rowType, ok := classifyAccount(raw, name) + if !ok { + reportAccountStderrLine(deps, fmt.Sprintf("Error: %s. Known candidates: %s", errorText, candidateNames(raw))) + return 1 + } + targets = append(targets, target{name: name, rowType: rowType}) + } else { + seen := map[string]bool{} + push := func(n string) { + if seen[n] { + return + } + seen[n] = true + _, rowType, ok := classifyAccount(raw, n) + if !ok { + return + } + targets = append(targets, target{name: n, rowType: rowType}) + } + push("openai") + providersRes := accountHTTP(deps.httpClientOr(), baseURL, "GET", "/api/oauth/providers", nil) + if providersRes.status == 0 { + return reportProxyUnreachable(deps.deps, providersRes.transportError) + } + if providersRes.status != 200 { + return accountAPIError(deps.deps, providersRes.body, "failed to list OAuth providers", providersRes.status) + } + if providers := activeOrEmptyArray(providersRes.body, "providers"); providers != nil { + for _, provider := range providers { + if provider.Kind() == jsonwire.String { + push(provider.String()) + } + } + } + providersSection, _ := raw["providers"].(map[string]any) + for providerName := range providersSection { + push(providerName) + } + } + + var rows []*accountRow + var notes []string + for _, target := range targets { + var result accountFamilyRows + if wantsQuota { + result = fetchAccountRows(deps.httpClientOr(), baseURL, target.name, target.rowType, true, refreshQuota) + } else { + result = fetchAccountRows(deps.httpClientOr(), baseURL, target.name, target.rowType, false, false) + } + if result.networkDown { + return reportProxyUnreachable(deps.deps, result.transportErr) + } + if result.errorBody != nil { + if name != "" { + return accountAPIError(deps.deps, result.errorBody, fmt.Sprintf("failed to list %s", target.name), result.status) + } + errorText := objectString(result.errorBody, "error") + skipUnknownKey := target.rowType == accountTypeAPIKey && result.status == 404 && strings.Contains(errorText, "unknown provider") + skipConfigOAuth := target.rowType == accountTypeOAuth && result.status == 400 && strings.Contains(errorText, "unknown oauth provider") + if skipUnknownKey || skipConfigOAuth { + continue + } + return accountAPIError(deps.deps, result.errorBody, fmt.Sprintf("failed to list %s", target.name), result.status) + } + if len(result.rows) == 0 { + if showAll { + notes = append(notes, fmt.Sprintf("%s: no stored accounts or keys", target.name)) + } + continue + } + rows = append(rows, result.rows...) + if target.rowType == accountTypeCodex { + if !result.hasActiveID { + notes = append(notes, "openai: auto (no pin — lowest-usage account is selected per request)") + } + providerRow, _ := configProviderMode(raw, "openai") + if codexAccountModeFor("openai", providerRow) == "direct" { + notes = append(notes, "openai is in direct mode — the selection takes effect when pool mode is enabled") + } + } + } + + if wantsJSON { + value := jsonwire.ObjectValue() + accounts := jsonwire.EmptyArray() + for _, row := range rows { + accounts.AppendArray(accountRowObject(row, wantsQuota)) + } + value.Set("accounts", accounts) + noteArray := jsonwire.EmptyArray() + for _, note := range notes { + noteArray.AppendArray(jsonwire.StringValue(note)) + } + value.Set("notes", noteArray) + printPrettyJSON(deps.deps, value) + return 0 + } + if len(rows) > 0 { + fmt.Fprintln(deps.deps.Stdout, formatAccountTable(rows, wantsQuota)) + } + for _, note := range notes { + fmt.Fprintln(deps.deps.Stdout, note) + } + if len(rows) == 0 && len(notes) == 0 { + fmt.Fprintln(deps.deps.Stdout, "No stored accounts or keys.") + } + return 0 +} + +// runAccountCurrent mirrors cmdCurrent in account.ts. +func runAccountCurrent(rest []string, deps accountDeps) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + name := accountShift(&rest) + leftover := accountLeftoverError(rest) + if name == "" || leftover != "" { + if leftover != "" { + reportAccountStderrLine(deps, leftover) + } + reportAccountUsage(deps, accountUsage) + return 1 + } + raw := loadAccountConfigRaw() + errorText, rowType, ok := classifyAccount(raw, name) + if !ok { + reportAccountStderrLine(deps, fmt.Sprintf("Error: %s. Known candidates: %s", errorText, candidateNames(raw))) + return 1 + } + baseURL := resolveAccountBaseURL(deps) + if baseURL == "" { + return reportProxyUnreachable(deps.deps, "") + } + result := fetchAccountRows(deps.httpClientOr(), baseURL, name, rowType, false, false) + if result.networkDown { + return reportProxyUnreachable(deps.deps, result.transportErr) + } + if result.errorBody != nil { + return accountAPIError(deps.deps, result.errorBody, fmt.Sprintf("failed to read %s", name), result.status) + } + var activeRow *accountRow + for _, row := range result.rows { + if row.active { + activeRow = row + break + } + } + if wantsJSON { + value := jsonwire.ObjectValue() + value.Set("provider", jsonwire.StringValue(name)) + value.Set("type", jsonwire.StringValue(string(rowType))) + if result.hasActiveID { + value.Set("activeId", jsonwire.StringValue(result.activeID)) + } else { + value.Set("activeId", jsonwire.NullValue()) + } + if result.hasAutoSwitch { + value.Set("autoSwitchThreshold", jsonwire.NumberFrom(result.autoSwitch)) + } + if activeRow != nil { + value.Set("account", accountRowObject(activeRow, false)) + } else { + value.Set("account", jsonwire.NullValue()) + } + printPrettyJSON(deps.deps, value) + return 0 + } + if activeRow != nil { + fmt.Fprintln(deps.deps.Stdout, formatAccountTable([]*accountRow{activeRow}, false)) + } else if rowType == accountTypeCodex && !result.hasActiveID { + fmt.Fprintln(deps.deps.Stdout, "openai: auto (no pin — lowest-usage account is selected per request)") + } else { + fmt.Fprintln(deps.deps.Stdout, fmt.Sprintf("%s: no active account or key", name)) + } + return 0 +} + +// runAccountUse mirrors cmdUse in account.ts. +func runAccountUse(rest []string, deps accountDeps) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + name := accountShift(&rest) + id := accountShift(&rest) + leftover := accountLeftoverError(rest) + if name == "" || id == "" || leftover != "" { + if leftover != "" { + reportAccountStderrLine(deps, leftover) + } + reportAccountUsage(deps, accountUsage) + return 1 + } + raw := loadAccountConfigRaw() + errorText, rowType, ok := classifyAccount(raw, name) + if !ok { + reportAccountStderrLine(deps, fmt.Sprintf("Error: %s. Known candidates: %s", errorText, candidateNames(raw))) + return 1 + } + baseURL := resolveAccountBaseURL(deps) + if baseURL == "" { + return reportProxyUnreachable(deps.deps, "") + } + var response accountAPIResult + activeID := "" + switch rowType { + case accountTypeCodex: + activeID = id + if id == mainAlias { + activeID = mainAccountID + } + body := jsonwire.ObjectValue() + body.Set("accountId", jsonwire.StringValue(activeID)) + response = accountHTTP(deps.httpClientOr(), baseURL, "PUT", "/api/codex-auth/active", body) + case accountTypeOAuth: + activeID = id + body := jsonwire.ObjectValue() + body.Set("provider", jsonwire.StringValue(name)) + body.Set("accountId", jsonwire.StringValue(id)) + response = accountHTTP(deps.httpClientOr(), baseURL, "PUT", "/api/oauth/accounts/active", body) + default: + activeID = id + body := jsonwire.ObjectValue() + body.Set("name", jsonwire.StringValue(name)) + body.Set("id", jsonwire.StringValue(id)) + response = accountHTTP(deps.httpClientOr(), baseURL, "PUT", "/api/providers/keys/active", body) + } + if response.status == 0 { + return reportProxyUnreachable(deps.deps, response.transportError) + } + if response.status != 200 { + return accountAPIError(deps.deps, response.body, fmt.Sprintf("failed to switch %s", name), response.status) + } + if wantsJSON { + value := jsonwire.ObjectValue() + value.Set("ok", jsonwire.BoolValue(true)) + value.Set("provider", jsonwire.StringValue(name)) + value.Set("type", jsonwire.StringValue(string(rowType))) + value.Set("activeId", jsonwire.StringValue(activeID)) + printPrettyJSON(deps.deps, value) + } else { + kind := "account" + if rowType == accountTypeAPIKey { + kind = "key" + } + fmt.Fprintln(deps.deps.Stdout, fmt.Sprintf("%s: active %s is now %s", name, kind, displayID(activeID))) + } + if rowType == accountTypeCodex { + reportAccountStderrLine(deps, "Takes effect immediately; running threads move on their next request, and in-flight requests keep the account they captured.") + active := accountHTTP(deps.httpClientOr(), baseURL, "GET", "/api/codex-auth/active", nil) + if active.status == 200 { + if number, ok := accountNumber(active.body, "autoSwitchThreshold"); ok && number > 0 { + reportAccountStderrLine(deps, fmt.Sprintf("Note: auto-switch (threshold %s%%) may override this pin.", jsonwire.FormatV8Number(number))) + } + } + } + return 0 +} + +func reportAccountStderrLine(deps accountDeps, line string) { + fmt.Fprintln(deps.deps.Stderr, line) +} + +func reportAccountUsage(deps accountDeps, usage string) { + fmt.Fprintln(deps.deps.Stderr, usage) +} + +func (d accountDeps) httpClientOr() *http.Client { + if d.httpClient != nil { + return d.httpClient + } + return defaults(d.deps).HTTPClient +} diff --git a/go/internal/ocxcli/account_extended_cmd.go b/go/internal/ocxcli/account_extended_cmd.go new file mode 100644 index 0000000000..39de41e06d --- /dev/null +++ b/go/internal/ocxcli/account_extended_cmd.go @@ -0,0 +1,395 @@ +// ocx account command switch and the API-mutation subcommands — Go-native port +// of src/cli/account.ts (cmdAccount switch) and the refresh/auto-switch/remove +// handlers in src/cli/account-extended.ts. All of these speak the management +// API through account_runtime; the differential oracle pins their bytes. +package ocxcli + +import ( + "fmt" + "math" + "strconv" + + "github.com/lidge-jun/opencodex/go/internal/jsonwire" +) + +// runAccount is cmdAccount in src/cli/account.ts. +func runAccount(args []string, deps Deps) int { + acc := accountDeps{deps: deps} + sub := "" + if len(args) > 0 { + sub = args[0] + } + rest := args[1:] + switch sub { + case "list": + return runAccountList(rest, acc) + case "current": + return runAccountCurrent(rest, acc) + case "use": + return runAccountUse(rest, acc) + case "refresh": + return runAccountRefresh(rest, acc) + case "auto-switch": + return runAccountAutoSwitch(rest, acc) + case "alias", "rename": + return runAccountAlias(rest, acc) + case "priority": + return runAccountPriority(rest, acc) + case "pause": + return runAccountPause(rest, acc, true) + case "resume": + return runAccountPause(rest, acc, false) + case "pause-exhausted": + return runAccountPauseExhausted(rest, acc) + case "strategy": + return runAccountStrategy(rest, acc) + case "sticky": + return runAccountSticky(rest, acc) + case "remove": + return runAccountRemove(rest, acc) + case "clear-cooldown": + return runAccountClearCooldown(rest, acc) + case "login", "reauth", "code", "cancel", "reset-credits": + // OAuth device flows against the management API (account-auth.ts): + // headless --code - / --no-wait paths are oracle-covered in + // go-cli-parity; the poll-until-settled browser flows run the same Go + // client with matching 2s cadence. + if code, ok := runAccountAuthCommand(sub, rest, acc); ok { + return code + } + return 1 + default: + // account add-key/import/main stay TypeScript-owned: they need piped + // stdin I/O shapes or the native CODEX_HOME staging home that have no + // Go byte-oracle yet. cli.go gates them before dispatch. + reportAccountUsage(acc, accountUsage) + return 1 + } +} + +// accountExtendedUsageError mirrors usage() in account-extended.ts. +func accountExtendedUsageError(acc accountDeps, message string) int { + if message != "" { + reportAccountStderrLine(acc, message) + } + reportAccountUsage(acc, accountExtendedUsage) + return 1 +} + +func runAccountRefresh(rest []string, acc accountDeps) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + name := accountShift(&rest) + if name == "" || len(rest) > 0 { + return accountExtendedUsageError(acc, "") + } + raw := loadAccountConfigRaw() + errorText, rowType, ok := classifyAccount(raw, name) + if !ok { + return accountExtendedUsageError(acc, "Error: "+errorText) + } + baseURL := resolveAccountBaseURL(acc) + if baseURL == "" { + return reportProxyUnreachable(acc.deps, "") + } + if rowType != accountTypeCodex { + status, report, errorBody, transportErr := fetchProviderQuotaReport(acc.httpClientOr(), baseURL, name) + if status == 0 { + return reportProxyUnreachable(acc.deps, transportErr) + } + if status != 200 { + return accountAPIError(acc.deps, errorBody, fmt.Sprintf("failed to refresh %s", name), status) + } + if wantsJSON { + value := jsonwire.ObjectValue() + value.Set("provider", jsonwire.StringValue(name)) + if report != nil { + value.Set("report", report) + } else { + value.Set("report", jsonwire.NullValue()) + } + printPrettyJSON(acc.deps, value) + } else if report != nil { + fmt.Fprintln(acc.deps.Stdout, accountProviderQuotaLine(name, report)) + } else if accountHasPassiveQuota(name) { + fmt.Fprintln(acc.deps.Stdout, name+" reports usage only during a streaming response; there is nothing to refresh. Run a request through this provider to update it, then see `ocx account list "+name+"`.") + } else { + fmt.Fprintln(acc.deps.Stdout, "no quota report available for "+name) + } + return 0 + } + result := fetchCodexRows(acc.httpClientOr(), baseURL, true, true) + if failed := accountFamilyFailure(acc.deps, result, fmt.Sprintf("failed to refresh %s", name)); failed != nil { + return *failed + } + if wantsJSON { + value := jsonwire.ObjectValue() + accounts := jsonwire.EmptyArray() + for _, row := range result.rows { + accounts.AppendArray(accountRowObject(row, true)) + } + value.Set("accounts", accounts) + printPrettyJSON(acc.deps, value) + } else { + for _, row := range result.rows { + fmt.Fprintln(acc.deps.Stdout, accountRefreshLine(row)) + } + } + return 0 +} + +func allDigits(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func accountIsInteger(value float64) bool { + return value == math.Trunc(value) +} + +func hasNumberField(object *jsonwire.Value, key string) bool { + _, ok := accountNumber(object, key) + return ok +} + +func runAccountAutoSwitch(rest []string, acc accountDeps) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + name := accountShift(&rest) + action := accountShift(&rest) + if name == "" || action == "" { + return accountExtendedUsageError(acc, "") + } + raw := loadAccountConfigRaw() + _, rowType, ok := classifyAccount(raw, name) + genericPool := rowType == accountTypeOAuth + if !ok || rowType == accountTypeAPIKey || name == "anthropic" { + return accountExtendedUsageError(acc, "Error: auto-switch only applies to the openai Codex account pool or a generic OAuth provider pool") + } + var threshold *float64 + switch { + case action == "on" && len(rest) == 0: + value := 80.0 + threshold = &value + case action == "off" && len(rest) == 0: + value := 0.0 + threshold = &value + case action == "threshold" && len(rest) == 1 && allDigits(rest[0]): + if number, err := strconv.ParseFloat(rest[0], 64); err == nil { + threshold = &number + } + case action != "status" || len(rest) != 0: + return accountExtendedUsageError(acc, "") + } + if threshold != nil && (!accountIsInteger(*threshold) || *threshold < 0 || *threshold > 100) { + return accountExtendedUsageError(acc, "Error: threshold must be an integer 0-100") + } + baseURL := resolveAccountBaseURL(acc) + if baseURL == "" { + return reportProxyUnreachable(acc.deps, "") + } + if action == "status" { + var response accountAPIResult + if genericPool { + response = accountHTTP(acc.httpClientOr(), baseURL, "GET", "/api/oauth/accounts/pool?provider="+urlQueryEscape(name), nil) + } else { + response = accountHTTP(acc.httpClientOr(), baseURL, "GET", "/api/codex-auth/active", nil) + } + if response.status == 0 { + return reportProxyUnreachable(acc.deps, response.transportError) + } + if response.status != 200 || (!genericPool && !hasNumberField(response.body, "autoSwitchThreshold")) { + return accountAPIError(acc.deps, response.body, "failed to read auto-switch status", response.status) + } + value, _ := accountNumber(response.body, "autoSwitchThreshold") + threshold = &value + } else { + var body *jsonwire.Value + var path string + if genericPool { + body = jsonwire.ObjectValue() + body.Set("provider", jsonwire.StringValue(name)) + body.Set("autoSwitchThreshold", jsonwire.NumberFrom(*threshold)) + path = "/api/oauth/accounts/pool" + } else { + body = jsonwire.ObjectValue() + body.Set("threshold", jsonwire.NumberFrom(*threshold)) + path = "/api/codex-auth/auto-switch" + } + response := accountHTTP(acc.httpClientOr(), baseURL, "PUT", path, body) + if response.status == 0 { + return reportProxyUnreachable(acc.deps, response.transportError) + } + if response.status != 200 { + return accountAPIError(acc.deps, response.body, "failed to update auto-switch", response.status) + } + } + enabled := *threshold > 0 + if wantsJSON { + value := jsonwire.ObjectValue() + value.Set("provider", jsonwire.StringValue(name)) + value.Set("autoSwitchThreshold", jsonwire.NumberFrom(*threshold)) + value.Set("enabled", jsonwire.BoolValue(enabled)) + printPrettyJSON(acc.deps, value) + } else if enabled { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("auto-switch: on (threshold %s%%)", jsonwire.FormatV8Number(*threshold))) + } else { + fmt.Fprintln(acc.deps.Stdout, "auto-switch: off") + } + return 0 +} + +// accountDeletePath mirrors deletePath() in account-extended.ts. +func accountDeletePath(rowType AccountType, name, id string) string { + switch rowType { + case accountTypeCodex: + return "/api/codex-auth/accounts?id=" + urlQueryEscape(id) + case accountTypeOAuth: + return "/api/oauth/accounts?provider=" + urlQueryEscape(name) + "&id=" + urlQueryEscape(id) + default: + return "/api/providers/keys?name=" + urlQueryEscape(name) + "&id=" + urlQueryEscape(id) + } +} + +func errorTextOf(json *jsonwire.Value, fallback string) string { + text := objectString(json, "error") + if text == "" { + return fallback + } + return text +} + +func runAccountRemove(rest []string, acc accountDeps) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + confirmed := consumeAccountFlag(&rest, "--yes") + fail := func(message string) int { + if wantsJSON { + value := jsonwire.ObjectValue() + value.Set("error", jsonwire.StringValue(message)) + printPrettyJSON(acc.deps, value) + } else { + fmt.Fprintln(acc.deps.Stderr, "Error: "+message) + } + return 1 + } + name := accountShift(&rest) + requestedID := accountShift(&rest) + if name == "" || requestedID == "" || len(rest) > 0 { + if wantsJSON { + return fail("provider and account id are required") + } + return accountExtendedUsageError(acc, "") + } + if !confirmed { + message := fmt.Sprintf("Confirmation required. Re-run: ocx account remove %s %s --yes", name, requestedID) + if wantsJSON { + return fail(message) + } + return accountExtendedUsageError(acc, message) + } + raw := loadAccountConfigRaw() + errorText, rowType, ok := classifyAccount(raw, name) + if !ok { + if wantsJSON { + return fail(errorText) + } + return accountExtendedUsageError(acc, "Error: "+errorText) + } + id := requestedID + if rowType == accountTypeCodex && requestedID == mainAlias { + id = mainAccountID + } + if rowType == accountTypeCodex && id == mainAccountID { + if wantsJSON { + return fail("the main Codex App login cannot be removed") + } + return accountExtendedUsageError(acc, "Error: the main Codex App login cannot be removed") + } + baseURL := resolveAccountBaseURL(acc) + if baseURL == "" { + return fail("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.") + } + before := fetchAccountRows(acc.httpClientOr(), baseURL, name, rowType, false, false) + if before.networkDown { + return fail("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.") + } + if before.errorBody != nil { + return fail(errorTextOf(before.errorBody, fmt.Sprintf("failed to verify %s before removal", name))) + } + found := false + for _, row := range before.rows { + if row.id == id { + found = true + break + } + } + if !found { + if wantsJSON { + return fail(fmt.Sprintf("account or key %q was not found", requestedID)) + } + return accountExtendedUsageError(acc, fmt.Sprintf("Error: account or key %q was not found", requestedID)) + } + response := accountHTTP(acc.httpClientOr(), baseURL, "DELETE", accountDeletePath(rowType, name, id), nil) + if response.status == 0 { + return fail("Proxy not reachable. Start it with 'ocx start' or 'ocx ensure'.") + } + if response.status != 200 { + return fail(errorTextOf(response.body, fmt.Sprintf("failed to remove %s", requestedID))) + } + catalogRefreshPending := rowType == accountTypeCodex && codexCatalogRefreshPending(response.body) + after := fetchAccountRows(acc.httpClientOr(), baseURL, name, rowType, false, false) + if after.networkDown || after.errorBody != nil { + detail := "unknown error" + if after.networkDown { + detail = "proxy not reachable" + } else if after.errorBody != nil { + detail = objectString(after.errorBody, "error") + if detail == "" { + detail = "unknown error" + } + } + return fail(fmt.Sprintf("post-delete verification failed; delete may have succeeded: %s", detail)) + } + removedActive := before.hasActiveID && before.activeID == id + value := jsonwire.ObjectValue() + value.Set("ok", jsonwire.BoolValue(true)) + value.Set("provider", jsonwire.StringValue(name)) + value.Set("id", jsonwire.StringValue(id)) + value.Set("removedActive", jsonwire.BoolValue(removedActive)) + if after.hasActiveID { + value.Set("promotedActiveId", jsonwire.StringValue(after.activeID)) + } else { + value.Set("promotedActiveId", jsonwire.NullValue()) + } + if rowType == accountTypeCodex { + value.Set("catalogRefreshPending", jsonwire.BoolValue(catalogRefreshPending)) + } + if wantsJSON { + printPrettyJSON(acc.deps, value) + } else if rowType == accountTypeCodex && removedActive && !after.hasActiveID { + fmt.Fprintln(acc.deps.Stdout, "openai: auto (no pin — lowest-usage account is selected per request)") + } else if rowType == accountTypeOAuth { + if len(after.rows) > 0 { + fmt.Fprintln(acc.deps.Stdout, name+": active account is now "+after.activeID) + } else { + fmt.Fprintln(acc.deps.Stdout, name+": no accounts remaining") + } + } else if rowType == accountTypeAPIKey { + if len(after.rows) > 0 { + fmt.Fprintln(acc.deps.Stdout, name+": active key is now "+after.activeID) + } else { + fmt.Fprintln(acc.deps.Stdout, name+": no keys remaining") + } + } else { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: removed account %s", name, requestedID)) + } + if !wantsJSON && catalogRefreshPending { + warnIfCodexCatalogRefreshPending(acc, response.body) + } + return 0 +} diff --git a/go/internal/ocxcli/account_pool_cmd.go b/go/internal/ocxcli/account_pool_cmd.go new file mode 100644 index 0000000000..d4717e54be --- /dev/null +++ b/go/internal/ocxcli/account_pool_cmd.go @@ -0,0 +1,622 @@ +// ocx account selection-order subcommands (priority, pause/resume, +// pause-exhausted, strategy, sticky and alias) — byte-faithful Go-native port +// of the matching handlers in src/cli/account-extended.ts. +package ocxcli + +import ( + "fmt" + "strconv" + "strings" + + "github.com/lidge-jun/opencodex/go/internal/jsonwire" +) + +// accountPriorityPresets mirrors PRIORITY_PRESETS. +var accountPriorityPresets = map[string]float64{ + "first": 2, "earlier": 1, "normal": 0, "later": -1, "last": -2, +} + +// accountPriorityNames is the canonical key order for preset messages. +var accountPriorityNames = []string{"first", "earlier", "normal", "later", "last"} + +func accountPriorityPresetName(priority float64) string { + for name, value := range accountPriorityPresets { + if value == priority { + return name + } + } + return "" +} + +func accountFormatPriority(priority float64) string { + preset := accountPriorityPresetName(priority) + signed := strconv.FormatFloat(priority, 'f', -1, 64) + if priority > 0 { + signed = "+" + signed + } + if preset != "" { + return signed + " (" + preset + ")" + } + return signed +} + +// accountParsePriorityArgument mirrors parsePriorityArgument: null = reset, +// nil-ok = unparseable. +func accountParsePriorityArgument(raw string) (*float64, bool) { + word := strings.ToLower(strings.TrimSpace(raw)) + if word == "reset" { + return nil, true + } + if _, ok := accountPriorityPresets[word]; ok { + value := accountPriorityPresets[word] + return &value, true + } + if !signedIntegerString(word) { + return nil, false + } + number, err := strconv.ParseFloat(word, 64) + if err != nil { + return nil, false + } + // parseAccountPriority returns null outside [-100,100]; that null maps to + // "unparseable" in parsePriorityArgument. + if number < -100 || number > 100 { + return nil, false + } + return &number, true +} + +func accountPriorityJSON(provider, id string, priority float64) *jsonwire.Value { + value := jsonwire.ObjectValue() + value.Set("ok", jsonwire.BoolValue(true)) + value.Set("provider", jsonwire.StringValue(provider)) + value.Set("id", jsonwire.StringValue(id)) + value.Set("priority", jsonwire.NumberFrom(priority)) + preset := accountPriorityPresetName(priority) + if preset != "" { + value.Set("preset", jsonwire.StringValue(preset)) + } else { + value.Set("preset", jsonwire.NullValue()) + } + return value +} + +// runAccountPriority mirrors cmdPriority. +func runAccountPriority(rest []string, acc accountDeps) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + name := accountShift(&rest) + requestedID := accountShift(&rest) + requestedPriority := accountShift(&rest) + if name == "" || requestedID == "" || len(rest) > 0 { + return accountExtendedUsageError(acc, "") + } + raw := loadAccountConfigRaw() + errorText, rowType, ok := classifyAccount(raw, name) + if !ok { + return accountExtendedUsageError(acc, "Error: "+errorText) + } + if rowType != accountTypeCodex { + return accountExtendedUsageError(acc, "Error: selection order only applies to the openai Codex account pool") + } + id := requestedID + if requestedID == mainAlias { + id = mainAccountID + } + // Validate before touching the network so a typo never reaches the proxy. + var priority *float64 + parseable := true + if requestedPriority != "" { + priority, parseable = accountParsePriorityArgument(requestedPriority) + if !parseable { + return accountExtendedUsageError(acc, "Error: selection order must be an integer -100..100, one of first/earlier/normal/later/last, or reset") + } + } + baseURL := resolveAccountBaseURL(acc) + if baseURL == "" { + return reportProxyUnreachable(acc.deps, "") + } + // No value means "show" — a read must not rewrite what it is reporting. + if requestedPriority == "" { + result := fetchCodexRows(acc.httpClientOr(), baseURL, false, false) + if failed := accountFamilyFailure(acc.deps, result, fmt.Sprintf("failed to read %s accounts", name)); failed != nil { + return *failed + } + var row *accountRow + for _, candidate := range result.rows { + if candidate.id == id { + row = candidate + break + } + } + if row == nil { + return accountExtendedUsageError(acc, "Error: no "+name+" account "+requestedID) + } + current := 0.0 + if row.hasPriority { + current = row.priority + } + if wantsJSON { + printPrettyJSON(acc.deps, accountPriorityJSON(name, id, current)) + } else { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: %s selection order is %s", name, requestedID, accountFormatPriority(current))) + } + return 0 + } + // "reset" parses to nil and is sent as JSON null (the API's default). + body := jsonwire.ObjectValue() + body.Set("id", jsonwire.StringValue(id)) + if priority != nil { + body.Set("priority", jsonwire.NumberFrom(*priority)) + } else { + body.Set("priority", jsonwire.NullValue()) + } + applied := 0.0 + if priority != nil { + applied = *priority + } + response := accountHTTP(acc.httpClientOr(), baseURL, "PUT", "/api/codex-auth/accounts/priority", body) + if response.status == 0 { + return reportProxyUnreachable(acc.deps, response.transportError) + } + if response.status != 200 { + return accountAPIError(acc.deps, response.body, fmt.Sprintf("failed to set selection order for %s", requestedID), response.status) + } + appliedPriority := applied + if number, ok := accountNumber(response.body, "priority"); ok { + appliedPriority = number + } + if wantsJSON { + printPrettyJSON(acc.deps, accountPriorityJSON(name, id, appliedPriority)) + } else { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: %s selection order is now %s", name, requestedID, accountFormatPriority(appliedPriority))) + } + reportAccountStderrLine(acc, "Takes effect from the next unbound request; running threads keep their current account until drained.") + reportAccountStderrLine(acc, `Also releases any manual "use this account now" pin, on any account.`) + return 0 +} + +// runAccountPause mirrors cmdPause. +func runAccountPause(rest []string, acc accountDeps, paused bool) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + verb := "pause" + if !paused { + verb = "resume" + } + name := accountShift(&rest) + requestedID := accountShift(&rest) + if name == "" || requestedID == "" || len(rest) > 0 { + return accountExtendedUsageError(acc, "") + } + raw := loadAccountConfigRaw() + errorText, rowType, ok := classifyAccount(raw, name) + if !ok { + return accountExtendedUsageError(acc, "Error: "+errorText) + } + if rowType != accountTypeCodex { + return accountExtendedUsageError(acc, "Error: "+verb+" applies to the openai Codex account pool") + } + id := requestedID + if requestedID == mainAlias { + id = mainAccountID + } + baseURL := resolveAccountBaseURL(acc) + if baseURL == "" { + return reportProxyUnreachable(acc.deps, "") + } + body := jsonwire.ObjectValue() + body.Set("id", jsonwire.StringValue(id)) + body.Set("paused", jsonwire.BoolValue(paused)) + response := accountHTTP(acc.httpClientOr(), baseURL, "PUT", "/api/codex-auth/accounts/pause", body) + if response.status == 0 { + return reportProxyUnreachable(acc.deps, response.transportError) + } + if response.status != 200 { + return accountAPIError(acc.deps, response.body, fmt.Sprintf("failed to %s %s", verb, requestedID), response.status) + } + if wantsJSON { + value := jsonwire.ObjectValue() + value.Set("ok", jsonwire.BoolValue(true)) + value.Set("provider", jsonwire.StringValue(name)) + value.Set("id", jsonwire.StringValue(id)) + value.Set("paused", jsonwire.BoolValue(paused)) + printPrettyJSON(acc.deps, value) + } else { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: %s %s", name, requestedID, verb+"d")) + } + if paused { + reportAccountStderrLine(acc, "Threads bound to this account are unbound, and a fallback account is selected if this one was active.") + } + return 0 +} + +// runAccountPauseExhausted mirrors cmdPauseExhausted. +func runAccountPauseExhausted(rest []string, acc accountDeps) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + name := accountShift(&rest) + if name == "" || len(rest) > 0 { + return accountExtendedUsageError(acc, "") + } + raw := loadAccountConfigRaw() + errorText, rowType, classified := classifyAccount(raw, name) + if !classified { + return accountExtendedUsageError(acc, "Error: "+errorText) + } + if rowType != accountTypeCodex { + return accountExtendedUsageError(acc, "Error: pause-exhausted applies to the openai Codex account pool") + } + baseURL := resolveAccountBaseURL(acc) + if baseURL == "" { + return reportProxyUnreachable(acc.deps, "") + } + body := jsonwire.ObjectValue() + response := accountHTTP(acc.httpClientOr(), baseURL, "PUT", "/api/codex-auth/accounts/pause-exhausted", body) + if response.status == 0 { + return reportProxyUnreachable(acc.deps, response.transportError) + } + if response.status != 200 { + return accountAPIError(acc.deps, response.body, "failed to pause exhausted accounts", response.status) + } + var pausedIDs []string + if field := response.body.Find("pausedAccountIds"); field != nil && field.Kind() == jsonwire.Array { + for _, value := range field.Elements() { + if value.Kind() == jsonwire.String { + pausedIDs = append(pausedIDs, value.String()) + } + } + } + checked, hasChecked := accountNumber(response.body, "checkedAccountCount") + failed, hasFailed := accountNumber(response.body, "failedAccountCount") + complete := !hasFailed || failed == 0 + ok := complete + if hasFailed && failed > 0 { + reportAccountStderrLine(acc, fmt.Sprintf("Quota refresh failed for %s account(s); those were not evaluated.", jsonwire.FormatV8Number(failed))) + } + if wantsJSON { + value := jsonwire.ObjectValue() + value.Set("ok", jsonwire.BoolValue(ok)) + value.Set("complete", jsonwire.BoolValue(complete)) + value.Set("provider", jsonwire.StringValue(name)) + ids := jsonwire.EmptyArray() + for _, id := range pausedIDs { + ids.AppendArray(jsonwire.StringValue(id)) + } + value.Set("pausedAccountIds", ids) + if hasChecked { + value.Set("checkedAccountCount", jsonwire.NumberFrom(checked)) + } else { + value.Set("checkedAccountCount", jsonwire.NullValue()) + } + if hasFailed { + value.Set("failedAccountCount", jsonwire.NumberFrom(failed)) + } else { + value.Set("failedAccountCount", jsonwire.NullValue()) + } + printPrettyJSON(acc.deps, value) + if !ok { + return 1 + } + return 0 + } + if len(pausedIDs) > 0 { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: paused %d exhausted account(s): %s", name, len(pausedIDs), strings.Join(pausedIDs, ", "))) + } else { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: no exhausted accounts to pause", name)) + } + if !ok { + return 1 + } + return 0 +} + +// runAccountStrategy mirrors cmdStrategy, runAccountSticky mirrors cmdSticky; +// both delegate to poolSetting. +func runAccountStrategy(rest []string, acc accountDeps) int { + return accountPoolSetting(rest, acc, "strategy") +} + +func runAccountSticky(rest []string, acc accountDeps) int { + return accountPoolSetting(rest, acc, "stickyLimit") +} + +func accountPoolSetting(rest []string, acc accountDeps, field string) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + label := "pool strategy" + if field == "stickyLimit" { + label = "sticky limit" + } + name := accountShift(&rest) + requested := accountShift(&rest) + if name == "" || len(rest) > 0 { + return accountExtendedUsageError(acc, "") + } + raw := loadAccountConfigRaw() + errorText, rowType, ok := classifyAccount(raw, name) + if !ok { + return accountExtendedUsageError(acc, "Error: "+errorText) + } + // poolTransportFor: codex → codex transport; oauth → anthropic transport; + // api-key → refusal string. + if rowType == accountTypeAPIKey { + return accountExtendedUsageError(acc, fmt.Sprintf("Error: pool settings apply to OAuth account pools, not the API-key provider %q", name)) + } + baseURL := resolveAccountBaseURL(acc) + if baseURL == "" { + return reportProxyUnreachable(acc.deps, "") + } + readPath := "/api/codex-auth/active" + writePath := "/api/codex-auth/pool-strategy" + strategyKey := "accountPoolStrategy" + stickyKey := "accountPoolStickyLimit" + writeBody := func(value *jsonwire.Value) *jsonwire.Value { + body := jsonwire.ObjectValue() + body.Set(field, value) + return body + } + if rowType == accountTypeOAuth { + readPath = "/api/oauth/accounts/pool?provider=" + urlQueryEscape(name) + writePath = "/api/oauth/accounts/pool" + strategyKey = "strategy" + stickyKey = "stickyLimit" + writeBody = func(value *jsonwire.Value) *jsonwire.Value { + body := jsonwire.ObjectValue() + body.Set("provider", jsonwire.StringValue(name)) + body.Set(field, value) + return body + } + } + // No value means show. + if requested == "" { + response := accountHTTP(acc.httpClientOr(), baseURL, "GET", readPath, nil) + if response.status == 0 { + return reportProxyUnreachable(acc.deps, response.transportError) + } + if response.status != 200 { + return accountAPIError(acc.deps, response.body, fmt.Sprintf("failed to read %s", label), response.status) + } + strategyField := response.body.Find(strategyKey) + stickyField := response.body.Find(stickyKey) + if wantsJSON { + value := jsonwire.ObjectValue() + value.Set("ok", jsonwire.BoolValue(true)) + value.Set("provider", jsonwire.StringValue(name)) + if strategyField != nil { + value.Set("strategy", strategyField) + } + if stickyField != nil { + value.Set("stickyLimit", stickyField) + } + printPrettyJSON(acc.deps, value) + } else { + shown := strategyField + if field == "stickyLimit" { + shown = stickyField + } + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: %s is %s", name, label, jsonwireWireText(shown))) + } + return 0 + } + // Sent as a number when it parses as one so the server sees the type it + // validates; a non-numeric string still goes through. + var value *jsonwire.Value + if field == "strategy" { + value = jsonwire.StringValue(requested) + } else { + number, err := strconv.ParseFloat(requested, 64) + if err != nil { + value = jsonwire.StringValue(requested) + } else { + value = jsonwire.NumberFrom(number) + } + } + response := accountHTTP(acc.httpClientOr(), baseURL, "PUT", writePath, writeBody(value)) + if response.status == 0 { + return reportProxyUnreachable(acc.deps, response.transportError) + } + if response.status != 200 { + return accountAPIError(acc.deps, response.body, fmt.Sprintf("failed to set %s", label), response.status) + } + if wantsJSON { + out := jsonwire.ObjectValue() + out.Set("ok", jsonwire.BoolValue(true)) + out.Set("provider", jsonwire.StringValue(name)) + if fieldResp := response.body.Find(strategyKey); fieldResp != nil { + out.Set("strategy", fieldResp) + } + if stickyResp := response.body.Find(stickyKey); stickyResp != nil { + out.Set("stickyLimit", stickyResp) + } + printPrettyJSON(acc.deps, out) + } else { + appliedField := response.body.Find(strategyKey) + if field == "stickyLimit" { + appliedField = response.body.Find(stickyKey) + } + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: %s is now %s", name, label, jsonwireWireText(appliedField))) + } + return 0 +} + +// jsonwireWireText renders a json value the way String(jsValue) does for the +// keys poolSetting prints: an absent key is "undefined", JSON null is "null", +// numbers use the V8 spelling. +func jsonwireWireText(value *jsonwire.Value) string { + if value == nil { + return "undefined" + } + switch value.Kind() { + case jsonwire.String: + return value.String() + case jsonwire.Number: + number, err := numberAsFloat(value) + if err != nil { + return "" + } + return jsonwire.FormatV8Number(number) + case jsonwire.Bool: + if value.Bool() { + return "true" + } + return "false" + case jsonwire.Null: + return "null" + default: + return "" + } +} + +// runAccountAlias mirrors cmdAlias. An alias of "-" (or an explicitly empty +// positional) clears the alias. +func runAccountAlias(rest []string, acc accountDeps) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + if len(rest) < 3 || len(rest) > 3 { + return accountExtendedUsageError(acc, "") + } + name := rest[0] + requestedID := rest[1] + requestedAlias := rest[2] + raw := loadAccountConfigRaw() + errorText, rowType, ok := classifyAccount(raw, name) + if !ok { + return accountExtendedUsageError(acc, "Error: "+errorText) + } + id := requestedID + if rowType == accountTypeCodex && requestedID == mainAlias { + id = mainAccountID + } + if id == mainAccountID { + return accountExtendedUsageError(acc, "Error: the main Codex App login cannot be renamed") + } + alias := requestedAlias + if alias == "-" { + alias = "" + } else { + alias = strings.TrimSpace(alias) + } + if len([]rune(alias)) > 80 || accountHasControlChars(alias) { + return accountExtendedUsageError(acc, "Error: alias must be at most 80 printable characters") + } + baseURL := resolveAccountBaseURL(acc) + if baseURL == "" { + return reportProxyUnreachable(acc.deps, "") + } + path := "/api/codex-auth/accounts/alias" + body := jsonwire.ObjectValue() + if rowType == accountTypeCodex { + body.Set("id", jsonwire.StringValue(id)) + body.Set("alias", jsonwire.StringValue(alias)) + } else if rowType == accountTypeOAuth { + path = "/api/oauth/accounts/alias" + body.Set("provider", jsonwire.StringValue(name)) + body.Set("accountId", jsonwire.StringValue(id)) + body.Set("alias", jsonwire.StringValue(alias)) + } else { + path = "/api/providers/keys/alias" + body.Set("name", jsonwire.StringValue(name)) + body.Set("id", jsonwire.StringValue(id)) + body.Set("alias", jsonwire.StringValue(alias)) + } + response := accountHTTP(acc.httpClientOr(), baseURL, "PUT", path, body) + if response.status == 0 { + return reportProxyUnreachable(acc.deps, response.transportError) + } + if response.status != 200 { + return accountAPIError(acc.deps, response.body, fmt.Sprintf("failed to rename %s", requestedID), response.status) + } + value := jsonwire.ObjectValue() + value.Set("ok", jsonwire.BoolValue(true)) + value.Set("provider", jsonwire.StringValue(name)) + value.Set("id", jsonwire.StringValue(id)) + if alias != "" { + value.Set("alias", jsonwire.StringValue(alias)) + } else { + value.Set("alias", jsonwire.NullValue()) + } + if wantsJSON { + printPrettyJSON(acc.deps, value) + } else if alias != "" { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: %s is now \u201c%s\u201d", name, requestedID, alias)) + } else { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: cleared alias for %s", name, requestedID)) + } + return 0 +} + +func accountHasControlChars(value string) bool { + for _, r := range value { + if r < 0x20 || r == 0x7f { + return true + } + } + return false +} + +// runAccountClearCooldown mirrors cmdClearCooldown in account-extended.ts. +func runAccountClearCooldown(rest []string, acc accountDeps) int { + wantsJSON := consumeAccountFlag(&rest, "--json") + name := accountShift(&rest) + requestedID := accountShift(&rest) + if name == "" || requestedID == "" || len(rest) > 0 { + return accountExtendedUsageError(acc, "") + } + raw := loadAccountConfigRaw() + errorText, rowType, ok := classifyAccount(raw, name) + if !ok { + return accountExtendedUsageError(acc, "Error: "+errorText) + } + if rowType != accountTypeCodex { + return accountExtendedUsageError(acc, "Error: "+name+" is not a Codex account pool; cooldown clearing applies to Codex accounts only") + } + id := requestedID + if requestedID == mainAlias { + id = mainAccountID + } + baseURL := resolveAccountBaseURL(acc) + if baseURL == "" { + return reportProxyUnreachable(acc.deps, "") + } + body := jsonwire.ObjectValue() + body.Set("id", jsonwire.StringValue(id)) + response := accountHTTP(acc.httpClientOr(), baseURL, "POST", "/api/codex-auth/accounts/clear-cooldown", body) + if response.status == 0 { + return reportProxyUnreachable(acc.deps, response.transportError) + } + if response.status != 200 { + return accountAPIError(acc.deps, response.body, fmt.Sprintf("failed to clear cooldown for %s", requestedID), response.status) + } + cleared := false + if field := response.body.Find("cleared"); field != nil && field.Kind() == jsonwire.Bool { + cleared = field.Bool() + } + if wantsJSON { + value := jsonwire.ObjectValue() + value.Set("ok", jsonwire.BoolValue(true)) + value.Set("provider", jsonwire.StringValue(name)) + value.Set("id", jsonwire.StringValue(id)) + value.Set("cleared", jsonwire.BoolValue(cleared)) + printPrettyJSON(acc.deps, value) + } else if cleared { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: cooldown lifted for %s", name, requestedID)) + } else { + fmt.Fprintln(acc.deps.Stdout, fmt.Sprintf("%s: no active cooldown for %s", name, requestedID)) + } + return 0 +} + +func signedIntegerString(value string) bool { + if value == "" { + return false + } + start := 0 + if value[0] == '+' || value[0] == '-' { + start = 1 + } + if start >= len(value) { + return false + } + for i := start; i < len(value); i++ { + if value[i] < '0' || value[i] > '9' { + return false + } + } + return true +} diff --git a/go/internal/ocxcli/account_refresh_helpers.go b/go/internal/ocxcli/account_refresh_helpers.go new file mode 100644 index 0000000000..cb7a132b18 --- /dev/null +++ b/go/internal/ocxcli/account_refresh_helpers.go @@ -0,0 +1,189 @@ +// Refresh-line and quota-parts renderers shared by the Go-owned `ocx account +// refresh` handler, mirroring refreshLine/quotaParts/providerQuotaLine and the +// passive-provider note in src/cli/account-extended.ts. +package ocxcli + +import ( + "fmt" + "math" + "net/http" + "strings" + + "github.com/lidge-jun/opencodex/go/internal/jsonwire" +) + +// accountHasPassiveQuota mirrors hasPassiveAccountQuota in src/providers/quota.ts: +// only meta-muse reports quota in-band and has no probe to refresh. +func accountHasPassiveQuota(name string) bool { + return name == "meta-muse" +} + +// codexCatalogRefreshPending mirrors codexCatalogRefreshPending in +// src/cli/account-catalog-refresh.ts (own-property descriptor check, no +// prototype walks). +func codexCatalogRefreshPending(json *jsonwire.Value) bool { + if json == nil || json.Kind() != jsonwire.Object { + return false + } + field := json.Find("catalogRefreshPending") + return field != nil && field.Kind() == jsonwire.Bool && field.Bool() +} + +const codexCatalogRefreshPendingWarning = "Warning: the account change was saved, but the Codex model catalog refresh is pending. Run 'ocx sync' to retry." + +func warnIfCodexCatalogRefreshPending(acc accountDeps, json *jsonwire.Value) { + if codexCatalogRefreshPending(json) { + reportAccountStderrLine(acc, codexCatalogRefreshPendingWarning) + } +} + +// isoResetState mirrors resetIso: it reports a reset timestamp string only for +// finite numbers; seconds (<1e10) are promoted to millis. +func accountResetISO(value float64, ok bool) (string, bool) { + if !ok { + return "", false + } + text := isoReset(value, true) + if text == "" { + return "", false + } + return text, true +} + +// accountQuotaParts mirrors quotaParts in account-extended.ts. +func accountQuotaParts(quota *jsonwire.Value) []string { + if quota == nil || quota.Kind() != jsonwire.Object { + return nil + } + var parts []string + add := func(label string, percent float64, ok bool, resetAt float64, hasReset bool) { + if !ok { + return + } + parts = append(parts, fmt.Sprintf("%s %s%%", label, jsonwire.FormatV8Number(percent))) + if reset, ok := accountResetISO(resetAt, hasReset); ok { + parts = append(parts, "resets "+reset) + } + } + percent, ok := accountNumber(quota, "fiveHourPercent") + resetAt, hasReset := accountNumber(quota, "fiveHourResetAt") + add("5h", percent, ok, resetAt, hasReset) + percent, ok = accountNumber(quota, "weeklyPercent") + resetAt, hasReset = accountNumber(quota, "weeklyResetAt") + add("weekly", percent, ok, resetAt, hasReset) + percent, ok = accountNumber(quota, "monthlyPercent") + resetAt, hasReset = accountNumber(quota, "monthlyResetAt") + add("monthly", percent, ok, resetAt, hasReset) + if windows := activeOrEmptyArray(quota, "customWindows"); windows != nil { + for _, window := range windows { + label := objectString(window, "label") + percent, ok = accountNumber(window, "percent") + resetAt, hasReset = accountNumber(window, "resetAt") + add(label, percent, ok, resetAt, hasReset) + } + } + return parts +} + +// accountProviderQuotaLine mirrors providerQuotaLine. +func accountProviderQuotaLine(name string, report *jsonwire.Value) string { + parts := []string{name} + if report != nil && report.Kind() == jsonwire.Object { + if quota := report.Find("quota"); quota != nil { + parts = append(parts, accountQuotaParts(quota)...) + } + } + return strings.Join(parts, " ") +} + +// accountRefreshLine mirrors refreshLine for a codex row: email/plan are +// dropped when empty, quota parts or the quota: unknown marker, then +// needs-reauth. +func accountRefreshLine(row *accountRow) string { + var parts []string + parts = append(parts, displayID(row.id)) + if row.hasEmail && row.email != "" { + parts = append(parts, row.email) + } + if row.hasPlan && row.plan != "" { + parts = append(parts, row.plan) + } + if row.paused { + parts = append(parts, "paused") + } + quotaParts := accountQuotaParts(row.quota) + if len(quotaParts) == 0 { + parts = append(parts, "quota: unknown") + } else { + parts = append(parts, strings.Join(quotaParts, " ")) + } + if row.needsReauthSet && row.needsReauth { + parts = append(parts, "needs-reauth") + } + var kept []string + for _, part := range parts { + if part != "" { + kept = append(kept, part) + } + } + return strings.Join(kept, " ") +} + +// accountQuotaPercent reads fiveHourPercent ?? shortPercent off a quota object. +func accountQuotaPercent(quota *jsonwire.Value) (float64, bool) { + for _, key := range []string{"fiveHourPercent", "shortPercent"} { + if number, ok := accountNumber(quota, key); ok { + return number, true + } + } + return 0, false +} + +// accountQuotaText mirrors quotaText in account.ts for the QUOTA table column. +func accountQuotaText(row *accountRow) string { + if row.quotaUnavailable { + return "unavailable" + } + if !row.hasQuota || row.quota == nil { + return "-" + } + var parts []string + if percent, ok := accountQuotaPercent(row.quota); ok { + parts = append(parts, "5h "+jsonwire.FormatV8Number(percent)+"%") + } + if percent, ok := accountNumber(row.quota, "weeklyPercent"); ok { + parts = append(parts, "wk "+jsonwire.FormatV8Number(percent)+"%") + } + if percent, ok := accountNumber(row.quota, "monthlyPercent"); ok { + parts = append(parts, "mo "+jsonwire.FormatV8Number(mathRound(percent))+"%") + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, " ") +} + +func mathRound(value float64) float64 { + return math.Floor(value + 0.5) +} + +// fetchProviderQuotaReport mirrors fetchProviderQuotaReport in account-api.ts: +// a refreshed quota report list is filtered for the requested provider. A +// missing report is not an error (report == nil, status 200). +func fetchProviderQuotaReport(client *http.Client, baseURL, name string) (int, *jsonwire.Value, *jsonwire.Value, string) { + response := accountHTTP(client, baseURL, "GET", "/api/provider-quotas?refresh=1", nil) + if response.status == 0 { + return 0, nil, response.body, response.transportError + } + if response.status != 200 { + return response.status, nil, response.body, "" + } + var report *jsonwire.Value + for _, candidate := range activeOrEmptyArray(response.body, "reports") { + if objectString(candidate, "provider") == name { + report = candidate + break + } + } + return 200, report, nil, "" +} diff --git a/go/internal/ocxcli/account_runtime.go b/go/internal/ocxcli/account_runtime.go new file mode 100644 index 0000000000..3ab6dfaa59 --- /dev/null +++ b/go/internal/ocxcli/account_runtime.go @@ -0,0 +1,584 @@ +// Shared data-access layer for the Go-owned `ocx account` command family. +// +// This mirrors src/cli/account-api.ts (live-proxy discovery, the /api/* +// management client and its transport sentinel, the account classification +// rules and the apiError taxonomy) plus the pieces of src/cli/account.ts and +// src/cli/account-extended.ts that every subcommand shares (the usage blocks, +// the account table renderer, candidate names). The differential oracle feeds +// the same fixture config + management payloads to the TypeScript CLI and this +// binary and requires byte-identical stdout/stderr and exit codes. +package ocxcli + +import ( + "fmt" + "io" + "math" + "net/http" + "strings" + "time" + + "github.com/lidge-jun/opencodex/go/internal/config" + "github.com/lidge-jun/opencodex/go/internal/jsonwire" +) + +// Exit codes from the account CLI taxonomy (account-api.ts apiError and the +// runtime-api runCliAction used by the account-auth subcommands). +const ( + accountExitMissing = 4 // RuntimeApiError 404 + accountExitConflict = 5 // RuntimeApiError 409 + mainAccountID = "__main__" + mainAlias = "main" +) + +// accountUsage is ACCOUNT_USAGE in src/cli/account.ts (the top-level usage the +// bare command and its list/current/use handlers print on stderr). +const accountUsage = `Usage: + ocx account list [provider] [--json] [--all] [--quota [--refresh]] + ocx account current [--json] + ocx account use [--json] + ocx account refresh [--json] + ocx account auto-switch > [--json] + ocx account alias [--json] + ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json] + ocx account pause [--json] + ocx account resume [--json] + ocx account pause-exhausted [--json] + ocx account strategy [] [--json] + ocx account sticky [<1-100>] [--json] + ocx account remove --yes [--json] + ocx account clear-cooldown [--json] + ocx account add-key [--label