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
+ },
})