diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 80d97cb51..2ebb5b650 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -228,6 +228,51 @@ jobs: echo "OpenCode state files:" ls -lA ~/.local/state/opencode || true + gateway-smoke: + if: ${{ github.event_name == 'push' || needs.setup.outputs.should-build == 'true' }} + name: Gateway Image Smoke Test + needs: [setup] + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup + - name: Build runtime + run: pnpm --filter @fro-bot/runtime build + - name: Build gateway + run: pnpm --filter @fro-bot/gateway build + - name: Assert no bare @fro-bot/runtime import in gateway bundle + run: | + if grep -q 'from "@fro-bot/runtime"' packages/gateway/dist/main.mjs; then + echo "REGRESSION: gateway dist has bare @fro-bot/runtime import" + exit 1 + fi + echo "OK: no bare @fro-bot/runtime import found in gateway bundle" + - name: Build gateway Docker image + run: docker build -f deploy/gateway.Dockerfile -t fro-bot-gateway:smoke . + - name: Smoke-test gateway image (assert config load, not module crash) + run: | + set +e + output="$(timeout 60s docker run --rm fro-bot-gateway:smoke 2>&1)" + status=$? + set -e + echo "--- docker run output ---" + echo "$output" + echo "--- exit status: $status ---" + if [ "$status" -eq 124 ]; then + echo "REGRESSION: gateway image hung on boot (timed out)" + exit 1 + fi + test "$status" -ne 0 + echo "$output" | grep -q "Missing required secret: DISCORD_TOKEN" + if echo "$output" | grep -q "ERR_MODULE_NOT_FOUND"; then + echo "REGRESSION: module resolution failed (ERR_MODULE_NOT_FOUND)" + exit 1 + fi + echo "OK: gateway reached config loading (DISCORD_TOKEN check), no module-resolution crash" + dependency-review: if: ${{ github.event_name == 'pull_request' }} name: Dependency Review diff --git a/assets/github-app-logo-512.png b/assets/github-app-logo-512.png new file mode 100644 index 000000000..e54577ed8 Binary files /dev/null and b/assets/github-app-logo-512.png differ diff --git a/assets/github-app-logo-alt.svg b/assets/github-app-logo-alt.svg new file mode 100644 index 000000000..89391f194 --- /dev/null +++ b/assets/github-app-logo-alt.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/github-app-logo.svg b/assets/github-app-logo.svg new file mode 100644 index 000000000..d553eac11 --- /dev/null +++ b/assets/github-app-logo.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/deploy/gateway.Dockerfile b/deploy/gateway.Dockerfile index 6f6ecc147..748383974 100644 --- a/deploy/gateway.Dockerfile +++ b/deploy/gateway.Dockerfile @@ -31,8 +31,6 @@ WORKDIR /app # Copy production node_modules from build stage COPY --from=build /workspace/node_modules ./node_modules -COPY --from=build /workspace/packages/runtime/package.json ./packages/runtime/package.json -COPY --from=build /workspace/packages/runtime/dist/ ./packages/runtime/dist/ COPY --from=build /workspace/packages/gateway/package.json ./packages/gateway/package.json COPY --from=build /workspace/packages/gateway/dist/ ./packages/gateway/dist/ diff --git a/docs/github-app-setup.md b/docs/github-app-setup.md new file mode 100644 index 000000000..45acf5687 --- /dev/null +++ b/docs/github-app-setup.md @@ -0,0 +1,47 @@ +# Fro Bot Agent — GitHub App Setup & Ownership Runbook + +This runbook records how the **Fro Bot Agent** GitHub App is registered and owned, and how an operator wires its credentials into their own gateway. For the public, user-facing description of the app, see [github-app.md](./github-app.md). + +> **Note on management:** GitHub does not reconcile App settings from a file in this repository — there is no GitOps for App configuration. This runbook is the source of truth for the App's intended configuration and for recreating it if needed. + +## Canonical app + +| Field | Value | +| ------------------ | ------------------------------------------------------- | +| Name | Fro Bot Agent | +| Owner | the `fro-bot` GitHub account | +| Public page / slug | https://github.com/apps/fro-bot-agent (`fro-bot-agent`) | +| Permission | `contents: read` only | +| Webhook | none | +| Visibility | Public (any account can install) | + +The gateway's default install URL points at this slug (`packages/gateway/src/config.ts`). Operators pointing at a different app override it with `GATEWAY_GITHUB_APP_INSTALL_URL`. + +## Registering the app (one-time, GitHub UI) + +App registration is a manual action in the GitHub UI; it cannot be automated from this repo. The canonical app above is already registered under the `fro-bot` account. These steps are for recreating it or registering a separate app under another account. + +1. Go to **GitHub → Settings → Developer settings → GitHub Apps → New GitHub App** (for the account that should own it). +2. **Name:** `Fro Bot Agent` (or your own name if registering a separate app). +3. **Homepage URL:** the gateway repo or your deployment docs. +4. **Webhook:** uncheck **Active** — the app needs no webhook. +5. **Permissions → Repository → Contents:** **Read-only**. Add nothing else. +6. **Where can this app be installed:** **Any account** (public) for the canonical app, or **Only on this account** for a private operator app. +7. **Create GitHub App.** Note the **App ID** on the settings page. +8. **Private keys → Generate a private key.** Save the downloaded `.pem` — this is the only copy. +9. (Optional) Upload the avatar from `assets/github-app-logo-512.png`. + +## Where credentials live + +Credentials belong to the **operator's own gateway deployment** — never committed to this repository. + +| Credential | Gateway env var | Compose secret file | +| ----------------- | ------------------------ | --------------------------------------- | +| App ID | `GITHUB_APP_ID` | `deploy/secrets/github-app-id` | +| Private key (PEM) | `GITHUB_APP_PRIVATE_KEY` | `deploy/secrets/github-app-private-key` | + +The compose stack reads these via the `*_FILE` convention (`GITHUB_APP_ID_FILE`, `GITHUB_APP_PRIVATE_KEY_FILE`). See the **GitHub App** section of [`deploy/README.md`](../deploy/README.md) for the full secret-wiring walkthrough and upgrade notes. + +## Installing on repositories + +Install the app on only the repositories Fro Bot should read (least privilege), then add them from Discord with `/fro-bot add-project `. See [github-app.md](./github-app.md) for install/uninstall details. diff --git a/docs/github-app.md b/docs/github-app.md new file mode 100644 index 000000000..3d86ea7ce --- /dev/null +++ b/docs/github-app.md @@ -0,0 +1,78 @@ +# Fro Bot Agent — GitHub App + +**Fro Bot Agent** is the GitHub App that lets a self-hosted [Fro Bot](https://github.com/fro-bot/agent) Discord gateway read a repository you choose, so you can drive Fro Bot against it from Discord. + +- **App:** https://github.com/apps/fro-bot-agent +- **Owner:** the `fro-bot` GitHub account +- **Permission:** `contents: read` — read-only access to repository contents. Nothing else. +- **Webhook:** none. The app receives no events from GitHub. + +## What it does + +When you run `/fro-bot add-project ` in a Discord server running a Fro Bot gateway, the gateway uses this app's installation to clone and read the repository you named. The read-only `contents` permission is the entire access surface — the app cannot write to your code, open pull requests, change settings, or read anything beyond repository contents. + +## Permissions + +| Permission | Access | Why | +| ------------------- | -------- | ---------------------------------------------------------------------- | +| Repository contents | **Read** | Clone and read the repo you explicitly add via `/fro-bot add-project`. | + +That is the complete list. The app requests no write scopes, no metadata beyond what `contents: read` implies, and no organization or account permissions. + +## Privacy + +The app is **inert unless you pair it with a Fro Bot gateway in your own Discord server**. Installing it grants read access; nothing happens until a gateway you control uses that access in response to a command you run. + +- This repository collects no data and operates no hosted service. There is no telemetry. +- All credentials and runtime live in **your** gateway deployment, not here. +- The app has no webhook, so GitHub sends it no events and it stores nothing on GitHub's side. + +## Install + +1. Open https://github.com/apps/fro-bot-agent and click **Install** (or **Configure**). +2. Choose the account or organization that owns the repositories you want Fro Bot to read. +3. Select **Only select repositories** and pick the repos you intend to add — least privilege. (You can add more later.) + +You only need to install on repositories you plan to use with `/fro-bot add-project`. + +## Uninstall + +Remove access at any time: + +1. Go to **Settings → Applications → Installed GitHub Apps** (for your account) or your org's **Settings → GitHub Apps**. +2. Find **Fro Bot Agent** and click **Configure**. +3. Remove individual repositories, or scroll to **Uninstall** to revoke all access. + +Uninstalling immediately revokes the gateway's ability to read your repositories. + +## Running your own gateway + +This app is only useful alongside a self-hosted gateway. To register your own app and wire credentials, see the [setup runbook](./github-app-setup.md). + +## GitHub App settings copy + +These values go in the App's **Basic Information** settings page (`fro-bot` account → Settings → Developer settings → GitHub Apps → Fro Bot Agent). + +### Description (paste into "Basic Information → Description") + +This field is displayed to users on the App's public page and renders markdown: + +```markdown +**Fro Bot Agent** gives a self-hosted [Fro Bot](https://github.com/fro-bot/agent) Discord gateway read-only access to repositories you choose, so you can drive Fro Bot against them from Discord. + +**Permission:** `contents: read` only — it can clone and read the repos you add, nothing else. No write access, no webhook. + +**Privacy:** inert until you pair it with a Fro Bot gateway in your own Discord server. No data is collected and no hosted service runs on your behalf; all credentials live in your own deployment. + +Add a repo from Discord with `/fro-bot add-project `. +``` + +### Adjacent fields + +- **Homepage URL:** `https://github.com/fro-bot/agent` +- **Webhook → Active:** unchecked (the app needs no webhook) +- **Badge background color:** `0D0216` (the Void brand color — frames the cyan token on near-black) + +### Short blurb (for any one-line listing) + +> Read-only repo access for your self-hosted Fro Bot Discord gateway — no webhook, inert until you use it. diff --git a/docs/solutions/best-practices/gateway-opencode-mention-loop-best-practices-2026-05-30.md b/docs/solutions/best-practices/gateway-opencode-mention-loop-best-practices-2026-05-30.md new file mode 100644 index 000000000..62e3e1295 --- /dev/null +++ b/docs/solutions/best-practices/gateway-opencode-mention-loop-best-practices-2026-05-30.md @@ -0,0 +1,215 @@ +--- +title: Gateway OpenCode mention-loop best practices +date: 2026-05-30 +category: best-practices +module: gateway +problem_type: best_practice +component: assistant +severity: high +related_components: + - runtime + - workspace-agent + - discord + - coordination +applies_when: + - remote-attaching a gateway to a workspace-bound OpenCode server + - forwarding authorization across both HTTP and SSE event streams + - recovering stale execution runs after a restart + - streaming partial agent output during long-running sessions + - enforcing single-run ownership and timeout cleanup +tags: + - gateway + - opencode + - remote-attach + - sse + - bearer-token + - recovery + - stream-flush + - abort-signal +--- + +# Gateway OpenCode mention-loop best practices + +Patterns from the gateway `@fro-bot` mention loop — an authorized Discord mention in a +bound channel drives an OpenCode session against the cloned repo. Captured after the work +cleared an 11-reviewer review pass plus a Fro Bot review that caught a P0 (recovery lock +ownership) and a blocking UX gap (partial output discarded on failure). These are the +load-bearing decisions worth reusing in later gateway units. + +Adjacent docs (different angle, not duplicates): +- `discord-slash-command-orchestration-patterns-2026-05-27.md` — the `/add-project` + orchestration side (members.fetch auth, partial-failure recovery, IAT handling). +- `signed-webhook-ingress-hardening-2026-05-29.md` — the inbound webhook trust boundary + (fail-closed auth, never-log-secret, no auth oracle). +- `../code-quality/architectural-issues-type-safety-and-resource-cleanup.md` — the + finally-guaranteed cleanup discipline this doc's dual-finally sharpens. + +## Context + +Unit 6 needed the gateway (one container) to drive OpenCode against a repo checked out in a +sandboxed workspace container. The instinct is to treat "OpenCode in another container" as a +special transport. It is not — and the wrong instinct leads to either breaking the sandbox +(running OpenCode in the gateway against a shared volume) or rebuilding the whole execution +loop inside the workspace. The patterns below are the middle path: reuse the proven HTTP+SSE +transport, put a thin auth boundary in front of it, and harden the failure and recovery edges +that only show up under crashes, timeouts, and concurrent triggers. + +## Guidance + +### 1. Remote attach is the same transport as in-process + +`createOpencode()` is `createOpencodeServer()` + `createOpencodeClient({baseUrl})` talking over +HTTP+SSE. "Remote attach" is just pointing `baseUrl` at another container — no special mode. +Crucially, the SDK's SSE path (`event.subscribe`) is **fetch-based, not `EventSource`**, so a +custom `Authorization` header survives on the `/event` stream, not only on HTTP calls. Verify +this kind of transport assumption with a throwaway spike before building on it. + +```ts +// packages/runtime/src/agent/remote-client.ts +const client = createOpencodeClient({baseUrl, headers}) +return {client, server: {url: baseUrl, close: () => {}}, shutdown: () => {}} +``` +```ts +// packages/gateway/src/execute/opencode-attach.ts — header injected here, never logged +export function attachOpencode(baseURL: string, token: string): OpenCodeServerHandle { + return createRemoteOpenCodeHandle(baseURL, {Authorization: `Bearer ${token}`}) +} +``` + +### 2. Bearer-token reverse proxy fronting a loopback-bound server + +OpenCode's SDK server has **no native auth**. So bind it to `127.0.0.1` only and make a +bearer-token reverse proxy the sole sandbox-net-reachable surface. The proxy validates with +`timingSafeEqual` (length pre-check, then constant-time compare) and rejects with a fixed 401 +body. Network isolation + proxy auth together are the trust model; neither alone is. + +```ts +// apps/workspace-agent/src/main.ts +const OPENCODE_PORT = 54321 +const OPENCODE_HOSTNAME = '127.0.0.1' // loopback only — never sandbox-net +const PROXY_PORT = 9200 // the only reachable surface +``` +```ts +// apps/workspace-agent/src/opencode-proxy.ts +let authorized = false +if (presentedBuf.length === expectedBuf.length) authorized = timingSafeEqual(presentedBuf, expectedBuf) +if (authorized === false) { res.writeHead(401, {'Content-Type': 'text/plain'}); res.end(UNAUTHORIZED_BODY); return } +``` + +### 3. Gate stale-run lock release on `run_id` ownership + +Startup recovery sweeps runs left `EXECUTING` by a crash → transitions them `FAILED` and +releases the repo lock. But it must **verify the current lock record's `run_id` matches the +stale run before releasing**. A stale run-state whose lease already expired may have had its +lock re-acquired by a newer, live run; releasing blindly deletes the newer run's lock and +permits concurrent execution against the same repo. This was a P0. + +```ts +// packages/gateway/src/execute/recovery.ts +const lockFetch = await fetchLockRecord(coordinationConfig, lockKeyResult.data, logger) +if (lockFetch !== null) { + if (lockFetch.runId === run.run_id) { + await releaseLock(coordinationConfig, repo, lockFetch.etag, coordLogger) + } else { + logger.warn({runId: run.run_id, repo, lockRunId: lockFetch.runId}, 'recovery: lock owned by another run — skip release') + } +} +``` + +An unparseable/missing `run_id` resolves to `null` → skip the release (fail safe: never delete +a lock you cannot prove belongs to the stale run). + +### 4. Flush partial output on failure paths + +A streaming sink flushed only on the success path silently discards everything on +timeout/error — so the most expensive failure (a 600s timeout) yields the least information. +Flush buffered output best-effort **in the catch path, before the coarse error reply**, in its +own try/catch so a flush failure cannot mask the original error. Guard against double-post. + +```ts +// packages/gateway/src/execute/run.ts (catch path) +if (sink !== null) { + await sink.flush().catch((flushError: unknown) => + logger.warn({repo, runId, err: String(flushError)}, 'run: sink.flush failed in error path')) +} +// ...then send the coarse, detail-free user message +``` + +### 5. Bounded execution + guaranteed resource release + +Thread `AbortSignal.timeout(runTimeoutMs)` into the event loop so a hung stream can't run +forever. Release resources in nested `finally` blocks: inner releases the lock + stops the +heartbeat, outer **always** releases the concurrency slot — both survive the timeout/throw +path. + +```ts +// packages/gateway/src/execute/run.ts +const timeoutSignal = AbortSignal.timeout(runTimeoutMs) +try { + try { + await runOpenCodeCore({handle, directory: binding.workspacePath, promptText, sink, signal: timeoutSignal, logger}) + } finally { + if (heartbeatStopped === false) await heartbeat.stop().catch(() => {}) + await releaseLock(coordinationConfig, repo, lockEtag, coordLogger) + } +} finally { + concurrency.release(channelId) // always, even on throw +} +``` + +### 6. EOF before the terminal signal is a failure; classify auth by status, not text + +A stream that ends **without** `session.idle` must throw (run marked `FAILED`), not resolve as +`COMPLETED` — otherwise a dropped connection looks like success while the agent may still be +mutating the repo. Separately, classify auth errors by **numeric status (401/403) only**; +substring-matching "401"/"unauthorized"/"forbidden" in a stringified error misclassifies +unrelated errors whose payload happens to contain those tokens. + +```ts +// packages/gateway/src/execute/run-core.ts +// signal aborted → 'timeout'; stream ended without session.idle → 'stream-ended' (both throw) +// auth detection: trust response.status === 401 || response.status === 403 only +``` + +## Why This Matters + +- **Transport clarity** — knowing remote attach is just a `baseUrl` swap (with fetch-based SSE + that honors headers) is what makes the bearer-proxy boundary viable instead of a rebuild. +- **The proxy is the trust boundary** because the server has no auth of its own. +- **Ownership-gated release** prevents one stale run from deleting a newer run's lock — a + silent concurrency-corruption P0 that no happy-path test catches. +- **Failure-path flush** preserves the only useful output in exactly the cases users care about + most (timeouts, mid-run failures). +- **Dual-finally + AbortSignal.timeout** make hangs and throws non-catastrophic instead of + slot/lock leaks that wedge a channel until the next restart. +- **Terminal-signal correctness** stops dropped streams from masquerading as completed work; + **numeric auth classification** avoids false "workspace unreachable" replies. + +## When to Apply + +- Attaching OpenCode (or any SDK server) across containers/hosts over HTTP+SSE. +- Fronting a no-auth local server with a bearer-token boundary. +- Recovering stale leases/locks at startup where another holder may have taken over. +- Streaming output to a user-visible sink during long-running async work. +- Any long-running orchestration with concurrency caps and a per-resource lock. +- Consuming SSE where the terminal event (not EOF) defines success. + +## Examples + +**Remote attach** — `createRemoteOpenCodeHandle(baseUrl, {Authorization: 'Bearer ' + token})`; +`event.subscribe()` carries the header because the SDK SSE path is fetch-based. + +**Proxy security** — bind OpenCode to `127.0.0.1`; expose only the reverse proxy; verify the +bearer with `timingSafeEqual`; reject with a fixed 401 body. + +**Recovery** — read the current lock record; release only when `lockFetch.runId === run.run_id`; +skip on mismatch or unparseable record. + +**Failure handling** — best-effort `sink.flush()` inside catch, then the coarse user reply; +never let a flush failure hide the real error. + +**Lifecycle** — `AbortSignal.timeout(runTimeoutMs)`; inner finally = heartbeat stop + lock +release; outer finally = concurrency slot release. + +**Stream correctness** — `session.idle` = success; EOF without idle = `stream-ended` (throw); +auth detection by `401/403` status, not message text. diff --git a/docs/solutions/build-errors/gateway-docker-runtime-resolution-crash-loop-2026-05-31.md b/docs/solutions/build-errors/gateway-docker-runtime-resolution-crash-loop-2026-05-31.md new file mode 100644 index 000000000..f4fe279e5 --- /dev/null +++ b/docs/solutions/build-errors/gateway-docker-runtime-resolution-crash-loop-2026-05-31.md @@ -0,0 +1,105 @@ +--- +title: Gateway Docker image crash-loop — workspace runtime externalized but not shipped +date: 2026-05-31 +category: build-errors +module: packages/gateway +problem_type: build_error +component: tooling +symptoms: + - Gateway Docker image crash-loops on boot + - ERR_MODULE_NOT_FOUND for @fro-bot/runtime resolving to src/index.ts + - Fails only in the built image; local dev and tests pass +root_cause: config_error +resolution_type: config_change +related_components: + - gateway + - runtime + - docker + - tsdown + - github-actions +tags: + - docker + - bundling + - tsdown-noexternal + - monorepo + - module-resolution + - ci-smoke +--- + +# Gateway Docker image crash-loop — workspace runtime externalized but not shipped + +## Problem + +The gateway Docker image crash-looped on boot with `ERR_MODULE_NOT_FOUND` for `@fro-bot/runtime`. The gateway bundle left the workspace package external, so at runtime Node tried to resolve a package entry (`src/index.ts`) that exists only in a source checkout — not in the image. This shipped silently across v0.45.0–v0.47.0 and forced a downstream deployer to pin back. + +## Symptoms + +- `ERR_MODULE_NOT_FOUND` on boot, resolving to `node_modules/@fro-bot/runtime/src/index.ts`. +- Crash-loop before the gateway finishes loading config. +- **Only in the built image** — local dev and `pnpm test` pass because `packages/runtime/src/` exists on the host checkout, so the bare import resolves there. + +## What Didn't Work / Why It Stayed Hidden + +- Host-checkout tests can't catch it: `packages/runtime/src/index.ts` is present locally, so the bare specifier resolves during dev and CI unit tests. +- Nothing in CI built or booted the gateway image, so an image-only packaging regression had no guard. +- Rejected alternative — pointing `packages/runtime/package.json` `exports` at compiled `dist/` (conditional exports `development`→src, `import`/`default`→dist): adds build-order coupling and risks the action tier, which inlines runtime at build time. Inlining the consumer bundle is simpler and matches existing precedent. + +## Solution + +Three changes (PR #708): + +**1. Inline `@fro-bot/runtime` into the gateway bundle** — `packages/gateway/tsdown.config.ts`: + +```ts +export default defineConfig({ + entry: ['src/main.ts'], + format: 'esm', + outDir: 'dist', + noExternal: id => { + if (id === '@fro-bot/runtime' || id.startsWith('@fro-bot/runtime/')) return true + return false + }, +}) +``` + +This mirrors the action tier, which already inlines the same workspace dep in the root `tsdown.config.ts` (`if (id.startsWith('@fro-bot/runtime')) return true`). The exact-or-subpath predicate avoids over-matching a hypothetical sibling like `@fro-bot/runtime-foo`. + +**2. Stop shipping the now-dead runtime files** — `deploy/gateway.Dockerfile` final stage: + +```dockerfile +# removed — bundle no longer references @fro-bot/runtime at runtime +- COPY --from=build /workspace/packages/runtime/package.json ./packages/runtime/package.json +- COPY --from=build /workspace/packages/runtime/dist/ ./packages/runtime/dist/ +``` + +**3. Add a CI image build + boot smoke** — `.github/workflows/ci.yaml` (`gateway-smoke` job). Build-time invariant plus an image boot that proves resolution, with a hang guard: + +```sh +# build-time: bundle must be self-contained +if grep -q 'from "@fro-bot/runtime"' packages/gateway/dist/main.mjs; then + echo "REGRESSION: gateway dist has bare @fro-bot/runtime import"; exit 1 +fi + +# boot the image with no secrets — must reach config load, not a module crash +output="$(timeout 60s docker run --rm fro-bot-gateway:smoke 2>&1)"; status=$? +[ "$status" -eq 124 ] && { echo "REGRESSION: image hung on boot"; exit 1; } +test "$status" -ne 0 +echo "$output" | grep -q "Missing required secret: DISCORD_TOKEN" +echo "$output" | grep -q "ERR_MODULE_NOT_FOUND" && { echo "REGRESSION: module resolution failed"; exit 1; } || true +``` + +## Why This Works + +Inlining removes the bare `@fro-bot/runtime` specifier from `dist/main.mjs`, so the image has nothing to resolve at boot — the dependency travels inside the bundle. That matches the action tier's established bundling rule. The CI smoke catches both the bad bundle shape (build-time grep) and the image-only boot failure (docker run), which host-checkout tests structurally cannot. + +## Prevention + +- **Rule:** when a bundler externalizes a workspace package whose published entry points at uncompiled `src/`, the consuming deployable must either inline it (`noExternal`) or ship the resolved `dist/`. Externalizing-and-not-shipping is the trap. +- **Guard:** any deployable image needs a CI build-and-boot smoke. Unit tests on a source checkout can't see image-only packaging gaps because `src/` is present locally. +- **Concrete invariant:** `grep -q 'from "@fro-bot/runtime"' packages/gateway/dist/main.mjs` returning true means the image is unsafe to ship. + +## Related + +- Issue #707 (fixed by PR #708, `721f213`). +- [Adding a Config-Declared Plugin to the Versioned Tool Pattern](../best-practices/versioned-tool-config-plugin-pattern-2026-03-29.md) — the same "verify how a dependency is actually resolved before copying a pattern" discipline, applied to workspace-package bundling. +- [Tool Binary Caching Across Ephemeral Runners](./tool-binary-caching-ephemeral-runners.md) — related CI-hygiene angle; note that cache optimization does not address image-only packaging gaps. diff --git a/packages/gateway/src/config.test.ts b/packages/gateway/src/config.test.ts index 9c4d6ca3f..adbcfc877 100644 --- a/packages/gateway/src/config.test.ts +++ b/packages/gateway/src/config.test.ts @@ -946,7 +946,7 @@ describe('loadGatewayConfig — GitHub App credentials', () => { const config = loadGatewayConfig() // #then - expect(config.gatewayGitHubAppInstallUrl).toBe('https://github.com/apps/fro-bot/installations/new') + expect(config.gatewayGitHubAppInstallUrl).toBe('https://github.com/apps/fro-bot-agent/installations/new') }) }) diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts index 3ea342df8..816d0067e 100644 --- a/packages/gateway/src/config.ts +++ b/packages/gateway/src/config.ts @@ -328,7 +328,7 @@ export function loadGatewayConfig(): GatewayConfig { const githubAppId = readSecret('GITHUB_APP_ID') const githubAppPrivateKey = readMultilineSecret('GITHUB_APP_PRIVATE_KEY') const gatewayGitHubAppInstallUrl = - readOptionalSecret('GATEWAY_GITHUB_APP_INSTALL_URL') ?? 'https://github.com/apps/fro-bot/installations/new' + readOptionalSecret('GATEWAY_GITHUB_APP_INSTALL_URL') ?? 'https://github.com/apps/fro-bot-agent/installations/new' const workspaceAgentUrl = readOptionalSecret('WORKSPACE_AGENT_URL') ?? 'http://workspace:9100' diff --git a/packages/gateway/src/discord/commands/add-project.test.ts b/packages/gateway/src/discord/commands/add-project.test.ts index 88cff80d8..b8cee67c0 100644 --- a/packages/gateway/src/discord/commands/add-project.test.ts +++ b/packages/gateway/src/discord/commands/add-project.test.ts @@ -167,7 +167,7 @@ function makeDeps(overrides?: Partial): AddProjectDeps { bindingsStore: makeBindingsStore(), appClient: makeAppClient(), workspaceClient: makeWorkspaceClient(), - installUrl: 'https://github.com/apps/fro-bot/installations/new', + installUrl: 'https://github.com/apps/fro-bot-agent/installations/new', logger: makeLogger(), ...overrides, } @@ -268,7 +268,7 @@ describe('executeAddProject', () => { await run(interaction, deps) // #then - expect(lastEditReplyContent(editReply)).toContain('https://github.com/apps/fro-bot/installations/new') + expect(lastEditReplyContent(editReply)).toContain('https://github.com/apps/fro-bot-agent/installations/new') }) it('aborts gracefully when appPermissions is null (DM interaction)', async () => { @@ -281,7 +281,7 @@ describe('executeAddProject', () => { await run(interaction, deps) // #then — aborts with install URL (treated as missing permissions) - expect(lastEditReplyContent(editReply)).toContain('https://github.com/apps/fro-bot/installations/new') + expect(lastEditReplyContent(editReply)).toContain('https://github.com/apps/fro-bot-agent/installations/new') }) it('aborts when bot has ManageChannels but not SendMessages', async () => { @@ -390,7 +390,13 @@ describe('executeAddProject', () => { const authForRepo = vi .fn() .mockResolvedValue( - err(new AppNotInstalledError('testowner', 'testrepo', 'https://github.com/apps/fro-bot/installations/new')), + err( + new AppNotInstalledError( + 'testowner', + 'testrepo', + 'https://github.com/apps/fro-bot-agent/installations/new', + ), + ), ) const {interaction, editReply} = makeInteraction({userId}) const deps = makeDeps({appClient: makeAppClient({authForRepo})}) diff --git a/packages/gateway/src/discord/commands/index.test.ts b/packages/gateway/src/discord/commands/index.test.ts index e05fd9c21..976879da0 100644 --- a/packages/gateway/src/discord/commands/index.test.ts +++ b/packages/gateway/src/discord/commands/index.test.ts @@ -26,7 +26,7 @@ function makeMockDeps(): AddProjectDeps { workspaceClient: { clone: vi.fn(), }, - installUrl: 'https://github.com/apps/fro-bot/installations/new', + installUrl: 'https://github.com/apps/fro-bot-agent/installations/new', logger: { info: vi.fn(), warn: vi.fn(), diff --git a/packages/gateway/src/github/app-client.test.ts b/packages/gateway/src/github/app-client.test.ts index f740b57bd..3571a79e5 100644 --- a/packages/gateway/src/github/app-client.test.ts +++ b/packages/gateway/src/github/app-client.test.ts @@ -24,7 +24,7 @@ vi.mock('@octokit/core', () => ({ const APP_ID = '12345' const PRIVATE_KEY = '-----BEGIN RSA PRIVATE KEY-----\nfake-key\n-----END RSA PRIVATE KEY-----' -const INSTALL_URL = 'https://github.com/apps/fro-bot/installations/new' +const INSTALL_URL = 'https://github.com/apps/fro-bot-agent/installations/new' const INSTALLATION_RESPONSE = { data: { diff --git a/packages/gateway/src/github/app-client.ts b/packages/gateway/src/github/app-client.ts index f5f1b7ba1..9d6958c1a 100644 --- a/packages/gateway/src/github/app-client.ts +++ b/packages/gateway/src/github/app-client.ts @@ -127,7 +127,7 @@ export interface AppClientOptions { * installation ID is cached in memory per (owner, repo) pair. */ export function createAppClient(options: AppClientOptions): AppClient { - const {appId, privateKey, installUrl = 'https://github.com/apps/fro-bot/installations/new', logger} = options + const {appId, privateKey, installUrl = 'https://github.com/apps/fro-bot-agent/installations/new', logger} = options // In-memory cache: "owner/repo" → installationId const installationCache = new Map() diff --git a/packages/gateway/src/program.test.ts b/packages/gateway/src/program.test.ts index d742cfaaa..7a3349e2f 100644 --- a/packages/gateway/src/program.test.ts +++ b/packages/gateway/src/program.test.ts @@ -99,7 +99,7 @@ function makeFakeConfig(overrides: Partial = {}): GatewayConfig { }, githubAppId: 'test-app-id', githubAppPrivateKey: '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----', - gatewayGitHubAppInstallUrl: 'https://github.com/apps/fro-bot/installations/new', + gatewayGitHubAppInstallUrl: 'https://github.com/apps/fro-bot-agent/installations/new', workspaceAgentUrl: 'http://workspace:9100', workspaceOpencodeUrl: 'http://workspace:9200', workspaceOpencodeToken: 'test-opencode-token', diff --git a/packages/gateway/tsdown.config.ts b/packages/gateway/tsdown.config.ts index 68cac3d04..c2dd2d7cb 100644 --- a/packages/gateway/tsdown.config.ts +++ b/packages/gateway/tsdown.config.ts @@ -4,4 +4,8 @@ export default defineConfig({ entry: ['src/main.ts'], format: 'esm', outDir: 'dist', + noExternal: id => { + if (id === '@fro-bot/runtime' || id.startsWith('@fro-bot/runtime/')) return true + return false + }, })