diff --git a/.agents/skills/versioned-tool/SKILL.md b/.agents/skills/versioned-tool/SKILL.md index 572eb7f845..b2cb32ce94 100644 --- a/.agents/skills/versioned-tool/SKILL.md +++ b/.agents/skills/versioned-tool/SKILL.md @@ -15,11 +15,12 @@ This project manages external CLI tools through a **single-source-of-truth versi | --- | --- | --- | --- | | OpenCode (action default) | `DEFAULT_OPENCODE_VERSION` | `opencode-version` | _Release-job coupled — NOT Renovate (see below)_ | | OpenCode (action fallback) | `FALLBACK_VERSION` in `src/services/setup/opencode.ts` | _(internal only)_ | `github-releases` (`anomalyco/opencode`) | -| OpenCode (workspace image) | `ARG OPENCODE_VERSION` in `deploy/workspace.Dockerfile` | _(internal only)_ | _Release-job coupled — NOT Renovate (see below)_ | +| OpenCode (workspace image, V1 default) | `ARG OPENCODE_VERSION` in `deploy/workspace.Dockerfile` | _(internal only)_ | _Release-job coupled — NOT Renovate (see below)_ | +| OpenCode (workspace image, V2) | `version` in `packages/runtime/src/shared/opencode-v2-pin.json`, passed as the workspace build's `--build-arg OPENCODE_VERSION` | _(internal only)_ | `github-releases` (`anomalyco/opencode`, major 2 only; custom manager on the JSON file) | | Systematic | `DEFAULT_SYSTEMATIC_VERSION` | `systematic-version` | `npm` (`@fro.bot/systematic`) | | OpenCode (harness base) | `base_version` in `packages/harness/harness.config.json` | _(internal only — config-JSON, not a TS constant)_ | `github-releases` (`anomalyco/opencode`) | -**Release-job coupled sites (not Renovate-tracked):** `DEFAULT_OPENCODE_VERSION` and the workspace `ARG OPENCODE_VERSION` hold the harness build version (`+harness.`). Renovate cannot order SemVer build-metadata tags, so both are bumped together by `harness-release.yaml`'s `sync-default-version` job at publish time (one auto-PR, dual-source idempotency guard). Do not add Renovate `customManager` entries for these — they would be dropped. See `docs/solutions/best-practices/cross-libc-build-and-release-safety-2026-06-14.md` §4 and `docs/solutions/workflow-issues/harness-base-version-source-of-truth-2026-06-12.md`. +**Release-job coupled sites (not Renovate-tracked):** `DEFAULT_OPENCODE_VERSION` and the workspace `ARG OPENCODE_VERSION` hold the harness build version (`+harness.`); the Dockerfile ARG default stays this V1 harness build, and V2 is selected by build-arg instead. Renovate cannot order SemVer build-metadata tags, so both are bumped together by `harness-release.yaml`'s `sync-default-version` job at publish time (one auto-PR, dual-source idempotency guard). Do not add Renovate `customManager` entries for these — they would be dropped. **V2 pin:** the JSON file's `version` is Renovate-tracked, but its per-asset digests are not; after a bump, run `node scripts/sync-opencode-v2-pin.ts` and commit the result (see `deploy/README.md` § OpenCode V2). See `docs/solutions/best-practices/cross-libc-build-and-release-safety-2026-06-14.md` §4 and `docs/solutions/workflow-issues/harness-base-version-source-of-truth-2026-06-12.md`. ## Quick Start diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 0f44b6a14a..daa1447a74 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -82,6 +82,18 @@ datasourceTemplate: 'github-releases', extractVersionTemplate: '^bun-v(?.*)$', }, + { + // Tracks the stock OpenCode V2 pin. The Dockerfile's `opencode-v2` stage and the Action's + // installOpenCode both read this file. Bumping `version` alone leaves the per-asset sha256 + // digests stale, and both consumers fail closed on that: the digests are regenerated by + // `scripts/sync-opencode-v2-pin.ts` (see the packageRules entry below). + customType: 'regex', + managerFilePatterns: ['/packages\\/runtime\\/src\\/shared\\/opencode-v2-pin\\.json/'], + matchStrings: ['"version":\\s*"(?\\d+\\.\\d+\\.\\d+)"'], + depNameTemplate: 'anomalyco/opencode', + datasourceTemplate: 'github-releases', + extractVersionTemplate: '^v?(?.*)$', + }, { // Tracks the packageManager field in package.json (bun@X.Y.Z). // CI installs this version through .github/actions/setup; keep it in lockstep with @@ -148,6 +160,21 @@ matchPackageNames: ['@opencode-ai/sdk', 'anomalyco/opencode'], allowedVersions: '<=1.19.4', }, + { + // The V2 pin lives in its own file and follows its own major, so the V1 cap above does not + // apply to it. This entry must stay AFTER that cap (a later rule wins) and must not become the + // first `anomalyco/opencode` rule with `allowedVersions`: pin-invariants.test.ts reads the cap + // from the first one. The pin PR cannot pass CI as Renovate opens it, because the per-asset + // digests still describe the old version; prBodyNotes tells the reviewer how to fix that. + matchFileNames: ['packages/runtime/src/shared/opencode-v2-pin.json'], + matchPackageNames: ['anomalyco/opencode'], + allowedVersions: '/^2\\./', + commitMessageTopic: 'OpenCode V2 pin', + automerge: false, + prBodyNotes: [ + 'This only bumps `version`. Regenerate the sha256 digests before merging: `node scripts/sync-opencode-v2-pin.ts`, then commit `packages/runtime/src/shared/opencode-v2-pin.json`. CI builds the V2 workspace image against the new pin.', + ], + }, { // Cap Bun at 1.4.4. The build gate requires an exact match with upstream // anomalyco/opencode's packageManager field at the harness base version. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fdf7a11705..9d211caf7f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -654,6 +654,101 @@ jobs: # watcher assertion above: nothing else asserts it, so pin it here. docker exec "$cid" sh -c '[ "$OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS" = "true" ]' echo "OK: clone API up on :9100, opencode present, base config (no baked model), watcher disabled, background subagents enabled, auth fail-soft" + - name: Build workspace Docker image (OpenCode V2) + run: | + # The V2 leg of this job. The Dockerfile selects the stock V2 binary when OPENCODE_VERSION + # has major 2 or higher; the only valid value is the pin, so it is read from the pin file + # (never hard-coded here) and bumps in one place. Every stage before the OpenCode version + # is shared with the V1 build above and comes from this runner's layer cache. + v2_version="$(node -p 'JSON.parse(fs.readFileSync("packages/runtime/src/shared/opencode-v2-pin.json", "utf8")).version')" + echo "V2 pin: ${v2_version}" + for attempt in 1 2 3; do + if docker build --build-arg "OPENCODE_VERSION=${v2_version}" -f deploy/workspace.Dockerfile -t fro-bot-workspace:smoke-v2 .; then + exit 0 + fi + echo "::warning::docker build (V2) attempt ${attempt} failed" + [ "${attempt}" -eq 3 ] && { echo "docker build (V2) failed after 3 attempts"; exit 1; } + sleep $((attempt * 15)) + done + - name: Smoke-test workspace image (OpenCode V2 boots through its entrypoint, no network) + run: | + set -euo pipefail + v2_version="$(node -p 'JSON.parse(fs.readFileSync("packages/runtime/src/shared/opencode-v2-pin.json", "utf8")).version')" + case "${v2_version}" in + [2-9].*|[1-9][0-9]*.*) : ;; + *) echo "opencode-v2-pin.json version is not a V2 version: ${v2_version}" >&2; exit 1 ;; + esac + + # 1. The stock musl binary starts as the agent uid with NO network, and both plugins load + # from the image's cache and report active. Plugin specs come from the runtime pins, the + # same values the Dockerfile installs (workspace-v2-image.test.mjs pins ARG == constant). + systematic_version="$(node -e "const m=fs.readFileSync('packages/runtime/src/shared/constants.ts','utf8').match(/DEFAULT_SYSTEMATIC_VERSION\s*=\s*'([^']+)'/); if(!m) process.exit(1); process.stdout.write(m[1])")" + plugin_version="$(node -e "const m=fs.readFileSync('packages/runtime/src/shared/constants.ts','utf8').match(/FRO_BOT_PLUGIN_VERSION\s*=\s*'([^']+)'/); if(!m) process.exit(1); process.stdout.write(m[1])")" + docker run --rm --network none --user 10001:10001 \ + -e HOME=/home/opencode -e XDG_CONFIG_HOME=/home/opencode/.config \ + -e XDG_DATA_HOME=/home/opencode/.local/share -e XDG_CACHE_HOME=/home/opencode/.cache \ + -e XDG_STATE_HOME=/home/opencode/.local/state \ + -v "$PWD/deploy/scripts/v2-image-smoke.mjs:/smoke.mjs:ro" \ + --entrypoint node fro-bot-workspace:smoke-v2 /smoke.mjs \ + "@fro.bot/systematic@${systematic_version}" "@fro.bot/opencode-plugin@${plugin_version}" + + # 2. The real entrypoint, with the production security posture and no network. /readyz is 200 + # only after the supervisor's plugin gate (systematic + fro-bot.opencode-plugin active) and + # permission gate pass, and it echoes the version the gateway selects its V2 loop from. + # Readiness needs no provider and no network: the plugins are installed at image build. + cid="$(docker run -d --network none \ + -e WORKSPACE_OPENCODE_TOKEN=dummy-smoke-token \ + -e MITMPROXY_CA_WAIT_SECONDS=0 \ + -e NO_PROXY='*' \ + $WORKSPACE_DOCKER_SECURITY_FLAGS \ + fro-bot-workspace:smoke-v2)" + cleanup() { docker rm -f "$cid" >/dev/null 2>&1 || true; } + trap cleanup EXIT + + body='' + ready=false + for _ in $(seq 1 90); do + if body="$(docker exec "$cid" curl -fsS --max-time 5 http://127.0.0.1:9100/readyz 2>/dev/null)"; then + ready=true + break + fi + sleep 1 + done + # Read the logs once: a `docker logs | grep -q` pipeline can SIGPIPE under pipefail. + logs="$(docker logs "$cid" 2>&1)" + echo "--- workspace logs ---" + echo "${logs}" + if [ "${ready}" != "true" ]; then + echo "REGRESSION: /readyz did not return 200 within 90s on the V2 image" + exit 1 + fi + echo "/readyz: ${body}" + echo "${body}" | node -e ' + const body = JSON.parse(fs.readFileSync(0, "utf8")) + if (body.ready !== true || body.opencode !== "ready" || body.opencodeVersion !== process.argv[1]) { + console.error("unexpected /readyz body for the V2 pin " + process.argv[1]) + process.exit(1) + } + ' "${v2_version}" + echo "${logs}" | grep -q "opencode-server-v2: ready" + if echo "${logs}" | grep -q "ERR_MODULE_NOT_FOUND"; then + echo "REGRESSION: module resolution failed (ERR_MODULE_NOT_FOUND)" + exit 1 + fi + + # 3. The binary is stock V2 at the pin: no +harness build. + oc_version="$(docker exec "$cid" opencode --version)" + echo "opencode --version: ${oc_version}" + if echo "${oc_version}" | grep -qF "harness"; then + echo "REGRESSION: V2 image reports a harness build: ${oc_version}" + exit 1 + fi + reported="${oc_version#opencode }" + if [ "${reported#v}" != "${v2_version}" ]; then + echo "REGRESSION: expected OpenCode ${v2_version}, got: ${oc_version}" + exit 1 + fi + echo "OK: V2 image ready on ${oc_version}, plugins active, no network" - name: Smoke-test auth provisioning (valid blob → auth.json written 0600) run: | set -e diff --git a/.github/workflows/opencode-plugin-release.yaml b/.github/workflows/opencode-plugin-release.yaml new file mode 100644 index 0000000000..327e94e781 --- /dev/null +++ b/.github/workflows/opencode-plugin-release.yaml @@ -0,0 +1,167 @@ +name: opencode-plugin-release + +on: + workflow_dispatch: + inputs: + version: + description: Version to publish (for example 0.1.0 or 0.2.0-rc.1) + required: true + type: string + dry_run: + description: 'Dry run: verify and pack, but do not publish' + required: false + type: boolean + default: false + +concurrency: + group: opencode-plugin-release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + verify: + name: verify (gates, build, pack) + runs-on: ubuntu-24.04 + + permissions: + contents: read + + outputs: + version: ${{ steps.params.outputs.version }} + dry_run: ${{ steps.params.outputs.dry_run }} + tarball: ${{ steps.pack.outputs.tarball }} + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Resolve version + id: params + env: + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + if ! printf '%s' "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::'${VERSION}' is not a plain semver version" + exit 1 + fi + + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + echo "dry_run=${DRY_RUN:-false}" >> "${GITHUB_OUTPUT}" + + - name: Setup Node.js and Bun + uses: ./.github/actions/setup + + - name: Lint, type-check and test + run: | + set -euo pipefail + bun run --filter @fro.bot/opencode-plugin lint + bun run --filter @fro.bot/opencode-plugin check-types + bun run --filter @fro.bot/opencode-plugin test + + - name: Build + run: bun run --filter @fro.bot/opencode-plugin build + + - name: Check the bundle only imports what the host provides + working-directory: packages/opencode-plugin + run: | + set -euo pipefail + node --input-type=module -e ' + import {readFileSync} from "node:fs" + const source = readFileSync("dist/index.mjs", "utf8") + const patterns = [/\bfrom\s*["\x27]([^"\x27]+)["\x27]/g, /\bimport\s*["\x27]([^"\x27]+)["\x27]/g, /\b(?:import|require)\(\s*["\x27]([^"\x27]+)["\x27]\s*\)/g] + const specifiers = patterns.flatMap(pattern => [...source.matchAll(pattern)].map(m => m[1])) + const allowed = (s) => s === "effect" || s.startsWith("effect/") || s === "@opencode/plugin" || s.startsWith("@opencode/plugin/") || s.startsWith("node:") + const bad = [...new Set(specifiers.filter(s => !allowed(s)))] + if (bad.length > 0) { console.error("::error::unexpected imports in dist/index.mjs: " + bad.join(", ")); process.exit(1) } + if (!specifiers.includes("@opencode/plugin")) { console.error("::error::dist/index.mjs no longer imports @opencode/plugin"); process.exit(1) } + console.log("bundle imports ok: " + [...new Set(specifiers)].join(", ")) + ' + + - name: Pack + id: pack + env: + VERSION: ${{ steps.params.outputs.version }} + run: | + set -euo pipefail + # Packed from a staging copy so the workspace manifest (workspace:* devDependencies) is never published. + STAGE="$(mktemp -d)" + PKG=packages/opencode-plugin + mkdir -p "${STAGE}/pkg" "${STAGE}/out" + cp -R "${PKG}/dist" "${PKG}/README.md" "${PKG}/LICENSE" "${PKG}/package.json" "${STAGE}/pkg/" + ( + cd "${STAGE}/pkg" + npm pkg set "version=${VERSION}" + npm pkg delete devDependencies scripts + npm pack --pack-destination "${STAGE}/out" + ) + TARBALL="$(ls "${STAGE}/out")" + mkdir -p dist-tarball + cp "${STAGE}/out/${TARBALL}" "dist-tarball/${TARBALL}" + echo "Packed contents:" + tar -tzf "dist-tarball/${TARBALL}" + echo "tarball=${TARBALL}" >> "${GITHUB_OUTPUT}" + + - name: Upload tarball + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: opencode-plugin-tarball + path: dist-tarball/ + if-no-files-found: error + retention-days: 7 + + # id-token is granted here only. This job runs none of the repository's code, so nothing in the build can obtain an + # OIDC token; it publishes the tarball verify produced. + publish: + name: publish (npm trusted publishing) + needs: verify + if: ${{ !cancelled() && needs.verify.result == 'success' && needs.verify.outputs.dry_run != 'true' }} + runs-on: ubuntu-24.04 + + permissions: + id-token: write + contents: read + + steps: + - name: Download tarball + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: opencode-plugin-tarball + path: dist-tarball + + - name: Setup Node.js (for npm trusted publishing via OIDC) + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + + - name: Check npm supports trusted publishing + run: | + version="$(npm --version)" + node -e 'const [a, b, c] = process.argv[1].split(".").map(Number); process.exit(a > 11 || (a === 11 && (b > 5 || (b === 5 && c >= 1))) ? 0 : 1)' "$version" \ + || { echo "::error::npm ${version} is older than 11.5.1, which trusted publishing requires"; exit 1; } + + - name: Publish to npm (trusted publishing) + env: + VERSION: ${{ needs.verify.outputs.version }} + TARBALL: ${{ needs.verify.outputs.tarball }} + run: | + set -euo pipefail + if npm view "@fro.bot/opencode-plugin@${VERSION}" version >/dev/null 2>&1; then + echo "Already published: @fro.bot/opencode-plugin@${VERSION}; skipping" + exit 0 + fi + + case "${VERSION}" in + *-*) DIST_TAG="next" ;; + *) DIST_TAG="latest" ;; + esac + + echo "Publishing @fro.bot/opencode-plugin@${VERSION} (dist-tag ${DIST_TAG})..." + npm publish "./dist-tarball/${TARBALL}" --tag "${DIST_TAG}" diff --git a/.gitignore b/.gitignore index faceeede60..50352091a8 100644 --- a/.gitignore +++ b/.gitignore @@ -113,6 +113,7 @@ eslint-typegen.d.ts packages/runtime/dist/ packages/gateway/dist/ packages/harness/dist/ +packages/opencode-plugin/dist/ apps/action/dist/ apps/workspace-agent/dist/ diff --git a/.node-version b/.node-version index ce62013c39..b74495a07d 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24.22.1 +24.22.2 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 64647cb01c..f1a1935855 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,7 +12,7 @@ This monorepo ships three distinct deployable surfaces from one codebase: - **`@fro-bot/gateway`** (`packages/gateway/`) — a Discord-first daemon that listens for `@fro-bot` mentions in bound guild channels and runs OpenCode inside a sandboxed workspace container. Includes an operator web surface (Hono, gateway-net only), an inbound announce webhook, and an S3-backed coordination layer. The `/fro-bot dispatch` slash command is a separate, simpler path: it asks the bound repo's `fro-bot.yaml` workflow to run via the GitHub Actions API and returns the run URL — it never touches the gateway's queue, concurrency cap, or run-state. - **`@fro.bot/harness`** (`packages/harness/`) — a patched OpenCode binary built via an LLM-merge integration pipeline. Published to npm and GitHub Releases; the Action setup phase downloads the checksum-verified release tarball (not the npm package) as the default OpenCode binary, and reuses an existing `opencode` only when it reports exactly the pinned version. The npm package ships `harness` and `opencode` commands (see [`packages/harness/README.md`](packages/harness/README.md)). -Supporting packages: `@fro-bot/runtime` (`packages/runtime/`) owns shared runtime primitives and version-pin constants; `@fro-bot/action` (`apps/action/`) is a thin workspace wrapper whose build produces the committed root `dist/`; `@fro-bot/workspace-agent` (`apps/workspace-agent/`) is the Hono HTTP sidecar inside the workspace container. +Supporting packages: `@fro-bot/runtime` (`packages/runtime/`) owns shared runtime primitives and version-pin constants; `@fro-bot/action` (`apps/action/`) is a thin workspace wrapper whose build produces the committed root `dist/`; `@fro-bot/workspace-agent` (`apps/workspace-agent/`) is the Hono HTTP sidecar inside the workspace container. `@fro.bot/opencode-plugin` (`packages/opencode-plugin/`) is the OpenCode V2 plugin published to npm: the `session_*` tools plus a guard that rejects `subagent` calls resuming a session. V2 skips a plugin that fails to load and keeps serving, so its id (`fro-bot.opencode-plugin`) is fixed and the surfaces confirm it by id; no surface loads it yet. ## Codemap @@ -63,7 +63,8 @@ Symbols verified against the live source tree. Where a symbol has moved to `pack | `createRemoteQuestionClient` | Function | `packages/runtime/src/agent/remote-question-client.ts` | v2-SDK `question` reply/reject client | | `TriggerDirective` | Interface | `packages/runtime/src/agent/prompt.ts` | Directive + appendMode for triggers | | `DEFAULT_SYSTEMATIC_VERSION` | Constant | `packages/runtime/src/shared/constants.ts` | Pinned Systematic version | -| `DEFAULT_OPENCODE_VERSION` | Constant | `packages/runtime/src/shared/constants.ts` | Pinned harness build version | +| `DEFAULT_OPENCODE_VERSION` | Constant | `packages/runtime/src/shared/constants.ts` | Pinned harness build (Action default; not the workspace image's) | +| `opencode-v2-pin.json` | JSON | `packages/runtime/src/shared/opencode-v2-pin.json` | Stock V2 version + asset sha256 | ### `packages/gateway/` @@ -300,7 +301,8 @@ Per-repo S3 conditional-write locks (`If-None-Match` / `If-Match`) built by `get - Root `session.idle` with a non-empty ledger enters drain. The drain-completion gate (`execute/drain-completion.ts`) requires each child's notice, current-generation root idle, and a REST-confirmed terminal root reply. Reconciliation settles tracked entries only; it never adopts. - Teardown settles through `settleOwnedSessions`; an unconfirmed root or child means `quarantined` (held lock, no slot hand-off). -- `deploy/plugins/no-task-reuse.mjs` rejects any `task` call with a non-empty `task_id`, so each background job has its own child session. +- `deploy/plugins/no-task-reuse.mjs` rejects any `task` call with a non-empty `task_id`, so each background job has its own child session. On V2 the `@fro.bot/opencode-plugin` guard does this. +- Each run picks its loop from the workspace's `/readyz` `opencodeVersion`: V1 `run-core.ts`, V2 `run-core-v2.ts` (with the `-v2` drain and settle modules). > See also: [Background Subagents and Ownership](docs/wiki/Background%20Subagents%20and%20Ownership.md#background-subagent-ownership-ledger-gateway) diff --git a/STRUCTURE.md b/STRUCTURE.md index b698a85e01..04f0984d47 100644 --- a/STRUCTURE.md +++ b/STRUCTURE.md @@ -42,6 +42,7 @@ fro-bot/agent/ │ │ ├── operator-contract/ # Operator contract types │ │ └── redaction/ # PII/secret redaction gate │ ├── harness/ # @fro.bot/harness — patched-OpenCode build + publish pipeline +│ ├── opencode-plugin/ # @fro.bot/opencode-plugin — OpenCode V2 plugin (session tools + subagent reuse guard) │ └── runtime/ # @fro-bot/runtime — shared runtime primitives + version-pin constants │ └── src/ │ ├── shared/ # Logger, types, constants (owns real version pins) @@ -94,12 +95,13 @@ fro-bot/agent/ - **`apps/workspace-agent/`** — Hono HTTP service that runs inside the workspace container (clone setup with staged root→agent ownership handoff, read-only `POST /inspect` checkout observation, `POST /update` checkout fast-forward, `POST /recover/preview`+`/recover` quarantine-and-replace, `GET`/`DELETE /backups` generations, bearer-gated control API, per-repo mutex and journal store, checkout-update git primitives, supervised unprivileged OpenCode); touch when adding workspace-side API endpoints. Serialize every mutating repo operation through `src/repo-mutex.ts` (also the sticky, restart-only maintenance hold) and record in-flight update/recovery state in `src/journal.ts`; real-git adversarial fixtures live in `src/update-fixtures/`. Import uid/gid/path constants from `src/identity.ts` (mirrored in `deploy/workspace.Dockerfile`) and run any git against an existing checkout through `src/git-safety.ts` as the agent uid — never as the root service. Checkout size/entry counts go through `src/agent-walk.ts`'s agent-uid or sealed-fd walker, never a root-owned path lookup. Checkout types in `src/types.ts` are mirrored in `packages/gateway/src/workspace-api/types.ts` and pinned by `scripts/checkout-types-drift-guard.test.ts` — change both sides together. - **`packages/gateway/`** — Discord-first daemon and operator web surface; the largest package, containing the mention loop, command handlers, approval gate, redaction pipeline, and the `web/ingress/` trusted-proxy client-address resolver that keys the unauthenticated operator-surface rate limits and OAuth bindings. - **`packages/harness/`** — Patched-OpenCode build and publish pipeline; touch when updating the bundled OpenCode binary. +- **`packages/opencode-plugin/`** — Fro Bot's OpenCode V2 plugin, published to npm: the `session_*` tools and the `subagent` session-reuse guard, bundled with the V2 session code from `@fro-bot/runtime/session-v2`; touch when changing what the plugin gives the agent. The V2 workspace image installs it from npm at build time and loads it at each OpenCode spawn. - **`packages/runtime/`** — Shared runtime primitives consumed by both `src/` and `packages/gateway/`; owns the authoritative version-pin constants. - **`deploy/`** — Docker Compose stack, Dockerfiles, mitmproxy egress topology, and deploy validation scripts. - **`deploy/plugins/`**, **`deploy/managed-config/`** — the baked OpenCode reuse-guard plugin (`no-task-reuse.mjs`) and the root-owned managed config (`/etc/opencode/opencode.json`) that loads it; plain Node ESM / JSON, tested from `deploy/scripts/*.test.mjs`. The same `no-task-reuse.mjs` is the single source for the Action's guard: `tsdown.config.ts` builds it into `dist/no-task-reuse.js`, and `src/services/setup/no-task-reuse-config.ts` copies it into the CI OpenCode config dir at every Action server start (`no-task-reuse.d.mts` types it for the Vitest suite). -- **`deploy/scripts/`** — Plain Node ESM (`.mjs`) helpers for deploy-time operations (auth validation, config merge, protected-dir creation, legacy checkout ownership migration, agent-side config provisioning); uses `node --test`, not Vitest. +- **`deploy/scripts/`** — Plain Node ESM (`.mjs`) helpers for deploy-time operations (auth validation, config merge, protected-dir creation, legacy checkout ownership migration, agent-side config provisioning, V2 plugin install and image smoke); uses `node --test`, not Vitest. - **`deploy/tests/`** — `isolation-harness.sh`, the CI acceptance harness that proves the service/agent uid boundary in a real container started with production's security posture. -- **`scripts/`** — Repo-level build tooling: action dist builder, hidden-Unicode scrubber, third-party notices, release dispatch. +- **`scripts/`** — Repo-level build tooling: action dist builder, hidden-Unicode scrubber, third-party notices, release dispatch, and `sync-opencode-v2-pin.ts` (regenerates the OpenCode V2 pin's digests on a Renovate bump). - **`evals/`** — Gated agent-outcome eval corpus that runs the real execution path against disposable fixture repos; the pure gate and baseline tests run in normal CI, while live scenarios require `FRO_BOT_EVAL=1`. - **`.github/workflows/`** — All CI/CD automation; 12 workflow files covering tests, releases, security scanning, and bot triggers. - **`RFCs/`** — 19 architecture specification documents; read before making cross-cutting changes. @@ -118,6 +120,7 @@ fro-bot/agent/ | `@fro-bot/workspace-agent` | `apps/workspace-agent/` | Hono HTTP service in the workspace container | | `@fro-bot/gateway` | `packages/gateway/` | Discord daemon, operator web surface, mention loop, redaction | | `@fro.bot/harness` | `packages/harness/` | Patched-OpenCode build and publish pipeline; ships the `harness` and `opencode` commands | +| `@fro.bot/opencode-plugin` | `packages/opencode-plugin/` | OpenCode V2 plugin: session tools and the subagent reuse guard; the `fro-bot.opencode-plugin` id | | `@fro-bot/runtime` | `packages/runtime/` | Shared runtime primitives; owns authoritative version-pin constants | ### CI Workflows @@ -131,6 +134,7 @@ fro-bot/agent/ | `fro-bot.yaml` | `issue_comment`, `issues`, `schedule`, `workflow_dispatch` | Fro Bot agent invocation (the Action under development) | | `harness-integrate.yaml` | `workflow_call` | LLM-merge integration of OpenCode refs into the harness build; called by `harness-release.yaml` | | `harness-release.yaml` | `push (harness-v* tags), workflow_dispatch` | Build matrix and publish for `@fro.bot/harness` | +| `opencode-plugin-release.yaml` | `workflow_dispatch` | Verify, pack and publish `@fro.bot/opencode-plugin` (npm trusted publishing) | | `osv-scanner.yaml` | `merge_group`, `pull_request`, `push`, `schedule` | PR-differential vulnerability enforcement plus report-only full scans for merge queue/main/scheduled runs | | `prepare-release-pr.yaml` | `schedule`, `workflow_dispatch` | Opens release PR via semantic-release | | `renovate.yaml` | `issues`, `pull_request`, `push`, `workflow_dispatch`, `workflow_run` | Renovate dependency update automation | diff --git a/apps/workspace-agent/AGENTS.md b/apps/workspace-agent/AGENTS.md index 98e66a7acb..09bfe26de8 100644 --- a/apps/workspace-agent/AGENTS.md +++ b/apps/workspace-agent/AGENTS.md @@ -37,7 +37,7 @@ Exposes `POST /clone` (clones a GitHub repo into `/workspace/repos/{owner}/{repo | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | /healthz | none | Liveness probe — returns `{ok: true}` | -| GET | /readyz | none | Readiness probe — gates on OpenCode + proxy state | +| GET | /readyz | none | Readiness probe — gates on OpenCode + proxy state; echoes `opencodeVersion` (the gateway picks its V1 or V2 loop from it) | | POST | /clone | bearer | Clone a GitHub repo into the workspace | | POST | /inspect | bearer | Read-only observation of an existing checkout | | POST | /update | bearer | Admission, bare-repo fetch, pack-stream import, journaled fast-forward of an existing checkout | @@ -137,6 +137,10 @@ src/ ├── recover.ts /recover/preview and /recover: fingerprint preview, quarantine-and-replace mutation ├── backups.ts /backups list/delete; the quarantine-generation metadata schema (including the optional run id) ├── opencode-instance.ts Best-effort POST /instance/dispose to the loopback OpenCode, run after a successful /recover +├── opencode-generation.ts Reads WORKSPACE_IMAGE_OPENCODE_VERSION (baked by the image); major 2+ selects the V2 trio below +├── opencode-server-v2.ts V2 supervisor: allowlisted env, fresh database per spawn, credential holder, plugin + permission gates +├── opencode-proxy-v2.ts V2 proxy: gateway bearer in, the ready server's Basic credential out, upstream 401 reported as such +├── opencode-instance-v2.ts V2 best-effort location evict (DELETE /api/debug/location), authenticated as the running server ├── checkout-profile.ts Config inventory, layout, and temp-index cleanliness admission checks shared by update/recover ├── git-safety.ts Sealed, credential-free git invocation profiles for git against an EXISTING checkout ├── git-stream.ts pack-objects | index-pack streaming primitive (runPackStream) @@ -150,6 +154,17 @@ src/ └── types.ts Request/response types (shared contract with gateway workspace-api) ``` +## OpenCode V2 (stock 2.x images) + +`deploy/workspace.Dockerfile` bakes `WORKSPACE_IMAGE_OPENCODE_VERSION` (the `OPENCODE_VERSION` that chose the binary). `main.ts` reads it through `readOpencodeGeneration`: major 2 or higher injects the V2 runner, proxy and disposer; unset or older keeps the V1 ones exactly. A value that is set but is not a version exits 1 before any bind. The same value is reported as `opencodeVersion` on `/healthz` and `/readyz`, and the gateway picks its run loop from it. The image pin is the only switch; there is no operator setting. + +- **Spawn** goes through `startOpenCodeV2Server` (`@fro-bot/runtime/workspace-v2`): `serve --stdio` with a held stdin, an explicit port, a per-spawn Basic password, readiness on `/api/agent`, and the plugin gate (Systematic `systematic` and `fro-bot.opencode-plugin` must be `active`). The child runs as uid/gid 10001 with an environment built from an allowlist. +- **Plugins are installed at image build time** by `deploy/scripts/install-opencode-v2-plugins.sh` (bounded, as uid 10001) and listed in the config content passed at spawn. Nothing installs at boot. +- **Every spawn gets a fresh database** (`prepareFreshDatabase`, under the root-owned `/var/lib/workspace-agent/opencode-v2-db`), so V2 has no execution claim to resume after a restart. +- **The credential holder** is written before readiness flips to ready and cleared on any exit, so the proxy answers 503 (never forwards a stale password) between servers. An upstream 401 is reported as a 502 with `x-fro-bot-proxy-error: upstream-auth`. +- **Shutdown** lists the sessions still active, interrupts them (reason `user`), closes stdin and confirms the exit, inside the 25s drain. `main.ts` waits for the V2 supervisor before it closes the proxy. +- **Permission gate:** after the plugin gate the supervisor checks the effective rules from `/api/agent` (`ask` or `deny` outside the checkout, `session_move` and `worktree_create` denied). A server that fails never serves. + ## Build ```bash @@ -161,7 +176,7 @@ bun run --filter @fro-bot/workspace-agent check-types ## Deployment -`deploy/workspace.Dockerfile` builds this package and bakes the OpenCode CLI (musl build, pinned to `DEFAULT_OPENCODE_VERSION`) plus a base `@fro.bot/systematic` plugin config. No model or provider is baked — those are deploy-time config. The container launches via `deploy/workspace-entrypoint.sh`, which (1) installs the mitmproxy CA into the **system** trust store (`update-ca-certificates`) — `git` and the `opencode` binary read the system CA bundle, so this is required for egress through the proxy; (2) validates the optional `workspace-opencode-auth` secret (API-key `auth.json` blob) and writes it to OpenCode's data path (`$XDG_DATA_HOME/opencode/auth.json`, `0600`) so the mention-loop agent can authenticate — written as a file, never exported as env, so the clone subprocess never sees it; and (3) overlays `WORKSPACE_OPENCODE_MODEL` (the `provider/model` string) and `WORKSPACE_OPENCODE_CONFIG` (a JSON `provider` block, e.g. a cliproxyapi `baseURL`) onto the base `opencode.json`, mirroring the action's `model` + `opencode-config` inputs (the Systematic plugin is always preserved). Auth and config are fail-soft when absent (clone-only boot) and fail fast when malformed. The supervisor (`main.ts`) then runs the clone API (9100), the loopback OpenCode server (54321), and the bearer proxy (9200). +`deploy/workspace.Dockerfile` builds this package and bakes the OpenCode CLI (musl build) plus a base config. A V1 image runs the harness build (`ARG OPENCODE_VERSION`, in step with `DEFAULT_OPENCODE_VERSION`) with a base config that lists `@fro.bot/systematic`. A V2 image is selected by building with `--build-arg OPENCODE_VERSION=`: it runs the stock binary verified against that pin, installs `@fro.bot/systematic` and `@fro.bot/opencode-plugin` at build time, and its base config is `{"update":"disable"}` with no plugin entry (the supervisor passes the plugins as config content at each spawn; see [deploy/README.md](../../deploy/README.md#opencode-v2)). `WORKSPACE_IMAGE_OPENCODE_VERSION` carries the version to `main.ts`, which picks the V1 or V2 supervisor. No model or provider is baked — those are deploy-time config. The container launches via `deploy/workspace-entrypoint.sh`, which (1) installs the mitmproxy CA into the **system** trust store (`update-ca-certificates`) — `git` and the `opencode` binary read the system CA bundle, so this is required for egress through the proxy; (2) validates the optional `workspace-opencode-auth` secret (API-key `auth.json` blob) and writes it to OpenCode's data path (`$XDG_DATA_HOME/opencode/auth.json`, `0600`) so the mention-loop agent can authenticate — written as a file, never exported as env, so the clone subprocess never sees it; and (3) overlays `WORKSPACE_OPENCODE_MODEL` (the `provider/model` string) and `WORKSPACE_OPENCODE_CONFIG` (a JSON `provider` block, e.g. a cliproxyapi `baseURL`) onto the base `opencode.json`, mirroring the action's `model` + `opencode-config` inputs (plugin entries already in the base are preserved; the V1 base lists Systematic, the V2 base lists none). Auth and config are fail-soft when absent (clone-only boot) and fail fast when malformed. The supervisor (`main.ts`) then runs the clone API (9100), the loopback OpenCode server (54321), and the bearer proxy (9200). Steps (2) and (3) are implemented by two checked-in Node ESM helpers in `deploy/scripts/` — `validate-auth.mjs` (`validateAuth`) and `merge-config.mjs` (`mergeConfig`) — which the entrypoint invokes (`node "$SCRIPTS_DIR/